merge main
This commit is contained in:
@@ -17,7 +17,7 @@ const ArchiveAllDropdown: React.FC<ArchiveAllDropdownProps> = ({ onArchiveAll })
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -11,7 +11,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -22,6 +24,11 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import {
|
||||
isFilesystemError,
|
||||
type FilesystemErrorReason,
|
||||
} from '@/lib/api/files-errors';
|
||||
|
||||
interface DirectoryExplorerDialogProps {
|
||||
open: boolean;
|
||||
@@ -142,13 +149,15 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const addProject = useProjectsStore((s) => s.addProject);
|
||||
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const gitIdentityProfiles = useGitIdentitiesStore((s) => s.profiles);
|
||||
const globalGitIdentity = useGitIdentitiesStore((s) => s.globalIdentity);
|
||||
const defaultGitIdentityId = useGitIdentitiesStore((s) => s.defaultGitIdentityId);
|
||||
const loadGitIdentityProfiles = useGitIdentitiesStore((s) => s.loadProfiles);
|
||||
const loadGlobalGitIdentity = useGitIdentitiesStore((s) => s.loadGlobalIdentity);
|
||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
|
||||
const { canRequestAccess, requestAccess, startAccessing } = useFileSystemAccess();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const addButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
@@ -158,6 +167,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const [entries, setEntries] = React.useState<BrowseEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isBrowseDirectoryMissing, setIsBrowseDirectoryMissing] = React.useState(false);
|
||||
const [browseErrorReason, setBrowseErrorReason] = React.useState<FilesystemErrorReason | null>(null);
|
||||
const [browseReloadKey, setBrowseReloadKey] = React.useState(0);
|
||||
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
|
||||
const [isConfirming, setIsConfirming] = React.useState(false);
|
||||
const [isOpeningFinder, setIsOpeningFinder] = React.useState(false);
|
||||
@@ -250,16 +261,19 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
React.useEffect(() => {
|
||||
if (!open || !browseDirectoryAbsolutePath) {
|
||||
setEntries([]);
|
||||
setBrowseErrorReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setIsBrowseDirectoryMissing(false);
|
||||
setBrowseErrorReason(null);
|
||||
opencodeClient.listLocalDirectory(browseDirectoryAbsolutePath)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setIsBrowseDirectoryMissing(false);
|
||||
setBrowseErrorReason(null);
|
||||
const nextEntries = result
|
||||
.filter((entry) => entry.isDirectory)
|
||||
.map((entry) => ({
|
||||
@@ -269,10 +283,12 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
setEntries(nextEntries);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setEntries([]);
|
||||
setIsBrowseDirectoryMissing(true);
|
||||
const reason = isFilesystemError(error) ? error.reason : 'unknown';
|
||||
setBrowseErrorReason(reason);
|
||||
setIsBrowseDirectoryMissing(reason === 'not-found');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -282,7 +298,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [browseDirectoryAbsolutePath, open]);
|
||||
}, [browseDirectoryAbsolutePath, browseReloadKey, open]);
|
||||
|
||||
const filteredEntries = React.useMemo(() => {
|
||||
const lowerFilter = browseFilterQuery.toLowerCase();
|
||||
@@ -327,20 +343,25 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
const shouldCreateTarget = Boolean(
|
||||
targetPath
|
||||
&& !isAlreadyAdded
|
||||
&& (browseErrorReason === null || browseErrorReason === 'not-found')
|
||||
&& (
|
||||
(hasTrailingPathSeparator(query) && isBrowseDirectoryMissing)
|
||||
|| (!hasTrailingPathSeparator(query) && browseFilterQuery.trim().length > 0 && exactEntry === null)
|
||||
)
|
||||
);
|
||||
const canAddProject = !isConfirming && !isOpeningFinder && !isAlreadyAdded && Boolean(targetPath);
|
||||
const canAddProject = !isConfirming
|
||||
&& !isOpeningFinder
|
||||
&& !isAlreadyAdded
|
||||
&& browseErrorReason !== 'os-permission'
|
||||
&& browseErrorReason !== 'invalid-response'
|
||||
&& browseErrorReason !== 'unknown'
|
||||
&& Boolean(targetPath);
|
||||
const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0;
|
||||
const highlightedRow = rows[highlightedIndex] ?? null;
|
||||
const hasHighlightedBrowseItem = Boolean(
|
||||
highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled))
|
||||
highlightedRow && (highlightedRow.type === 'up' || highlightedRow.type === 'directory')
|
||||
);
|
||||
const submitModifierLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
? '⌘'
|
||||
: 'Ctrl';
|
||||
const submitModifierLabel = formatShortcutForDisplay('mod');
|
||||
const submitActionLabel = isAlreadyAdded
|
||||
? t('directoryExplorerDialog.actions.alreadyAdded')
|
||||
: isCloneMode
|
||||
@@ -387,17 +408,25 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => {
|
||||
const openProjectDraft = React.useCallback((projectId: string, projectPath: string) => {
|
||||
if (isMobile) setSessionSwitcherOpen(false);
|
||||
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: projectPath });
|
||||
handleClose();
|
||||
}, [handleClose, isMobile, openNewSessionDraft, setSessionSwitcherOpen]);
|
||||
|
||||
const handleQuickAdd = React.useCallback(async (event: React.MouseEvent, path: string) => {
|
||||
event.stopPropagation();
|
||||
const normalized = normalizeDirectoryPath(path);
|
||||
if (normalized && addedProjectPaths.has(normalized)) return;
|
||||
const added = addProject(path);
|
||||
if (!added) {
|
||||
const project = await addProject(path);
|
||||
if (!project) {
|
||||
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
|
||||
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, [addProject, addedProjectPaths, t]);
|
||||
openProjectDraft(project.id, project.path);
|
||||
}, [addProject, addedProjectPaths, openProjectDraft, t]);
|
||||
|
||||
const finalizeSelection = React.useCallback(async (target: string) => {
|
||||
if (!target || isConfirming) return;
|
||||
@@ -421,16 +450,16 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
});
|
||||
selectedTarget = result.path;
|
||||
} else if (shouldCreateSelection) {
|
||||
await opencodeClient.createDirectory(target);
|
||||
await opencodeClient.createDirectory(target, { asProject: true });
|
||||
}
|
||||
const added = addProject(selectedTarget);
|
||||
if (!added) {
|
||||
const project = await addProject(selectedTarget);
|
||||
if (!project) {
|
||||
toast.error(t('directoryExplorerDialog.toast.failedToAddProject'), {
|
||||
description: t('directoryExplorerDialog.toast.selectValidDirectoryPath'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
openProjectDraft(project.id, project.path);
|
||||
} catch (error) {
|
||||
toast.error(t('directoryExplorerDialog.toast.failedToSelectDirectory'), {
|
||||
description: error instanceof Error ? error.message : t('directoryExplorerDialog.toast.unknownError'),
|
||||
@@ -438,7 +467,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
}, [addProject, addedProjectPaths, cloneRemoteUrl, handleClose, isCloneMode, isConfirming, selectedGitIdentity?.id, shouldCreateTarget, targetPath, t]);
|
||||
}, [addProject, addedProjectPaths, cloneRemoteUrl, isCloneMode, isConfirming, openProjectDraft, selectedGitIdentity?.id, shouldCreateTarget, targetPath, t]);
|
||||
|
||||
const browseToDisplayPath = React.useCallback((displayPath: string) => {
|
||||
setQuery(ensureBrowseDirectoryPath(displayPath));
|
||||
@@ -454,12 +483,11 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
if (row.path) browseToDisplayPath(row.path);
|
||||
return;
|
||||
}
|
||||
if (row.disabled) return;
|
||||
browseToEntry(row);
|
||||
}, [browseToDisplayPath, browseToEntry]);
|
||||
|
||||
const handleOpenInFinder = React.useCallback(async () => {
|
||||
if (!isDesktop || isOpeningFinder) return;
|
||||
if (!canRequestAccess || isOpeningFinder) return;
|
||||
setIsOpeningFinder(true);
|
||||
try {
|
||||
const result = await requestAccess(targetPath);
|
||||
@@ -488,7 +516,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
} finally {
|
||||
setIsOpeningFinder(false);
|
||||
}
|
||||
}, [finalizeSelection, isDesktop, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
|
||||
}, [canRequestAccess, finalizeSelection, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
|
||||
|
||||
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
@@ -596,6 +624,24 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
<div className="py-10 text-center typography-ui-label text-muted-foreground">
|
||||
{t('directoryExplorerDialog.browse.loading')}
|
||||
</div>
|
||||
) : browseErrorReason && browseErrorReason !== 'not-found' ? (
|
||||
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
|
||||
<div className="typography-ui-label text-status-error">
|
||||
{browseErrorReason === 'os-permission'
|
||||
? t('directoryExplorerDialog.browse.permissionDenied')
|
||||
: t('directoryExplorerDialog.browse.loadFailed')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{browseErrorReason === 'os-permission' && canRequestAccess ? (
|
||||
<Button size="xs" onClick={() => void handleOpenInFinder()} disabled={isOpeningFinder}>
|
||||
{t('directoryExplorerDialog.browse.grantAccess')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="xs" onClick={() => setBrowseReloadKey((key) => key + 1)}>
|
||||
{t('directoryExplorerDialog.browse.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="py-10 text-center typography-ui-label text-muted-foreground">
|
||||
{t('directoryExplorerDialog.browse.empty')}
|
||||
@@ -615,7 +661,6 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
disabled={row.type === 'directory' && row.disabled}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => executeRow(row)}
|
||||
@@ -623,7 +668,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
isActive && 'bg-interactive-selection text-interactive-selection-foreground',
|
||||
!isActive && 'hover:bg-interactive-hover/50',
|
||||
row.type === 'directory' && row.disabled && 'cursor-not-allowed opacity-45 hover:bg-transparent'
|
||||
row.type === 'directory' && row.disabled && 'opacity-45'
|
||||
)}
|
||||
>
|
||||
{row.type === 'up' ? (
|
||||
@@ -688,7 +733,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
<>
|
||||
{!isMobile ? footerHints : null}
|
||||
<div className={cn('flex w-full flex-row justify-end gap-2 sm:w-auto', isMobile && 'justify-stretch')}>
|
||||
{isDesktop ? (
|
||||
{canRequestAccess ? (
|
||||
<Button variant="ghost" size="xs" onClick={handleOpenInFinder} disabled={isConfirming || isOpeningFinder || isCloneMode}>
|
||||
{isOpeningFinder ? t('directoryExplorerDialog.actions.openingFinder') : t('directoryExplorerDialog.actions.openInFinder')}
|
||||
</Button>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
@@ -395,7 +396,7 @@ export function GitHubIssuePickerDialog({
|
||||
|
||||
const sessionTitle = `#${issue.number} ${issue.title}`.trim();
|
||||
|
||||
const { sessionId } = await (async () => {
|
||||
const { sessionId, sessionDirectory } = await (async () => {
|
||||
if (createInWorktree) {
|
||||
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
|
||||
const created = await createWorktreeSessionForNewBranch(
|
||||
@@ -449,6 +450,23 @@ export function GitHubIssuePickerDialog({
|
||||
const instructionsText = await renderMagicPrompt('github.issue.review.instructions');
|
||||
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
|
||||
|
||||
// Record the thread this session was created for, so it stays visible as
|
||||
// a context source once the opening message has scrolled away. A
|
||||
// snapshot, never re-fetched; a failed write must not fail the flow.
|
||||
void sessionActions.setLinkedIssue(
|
||||
sessionId,
|
||||
sessionDirectory,
|
||||
buildLinkedIssue({
|
||||
url: issue.url,
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
kind: 'issue',
|
||||
author: issue.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
|
||||
void useSessionUIStore.getState().sendMessage(
|
||||
visiblePromptText,
|
||||
providerID,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
||||
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
@@ -97,20 +98,6 @@ const normalizeBranchName = (value: string): string => {
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
};
|
||||
|
||||
const slugifyWorktreeName = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/').join('-')
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
};
|
||||
|
||||
const sanitizeRemoteName = (value: string): string => {
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
@@ -164,10 +151,14 @@ const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: st
|
||||
const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim();
|
||||
const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head';
|
||||
const remoteName = `pr-${sanitizeRemoteName(remoteSeed)}`;
|
||||
const remoteUrl = pr.headRepo?.sshUrl || pr.headRepo?.cloneUrl || '';
|
||||
// Prefer HTTPS so anonymous public fetches do not require SSH agent setup.
|
||||
const remoteUrl = pr.headRepo?.cloneUrl || pr.headRepo?.sshUrl || '';
|
||||
|
||||
if (!remoteUrl) {
|
||||
throw new Error('PR head repository URL is unavailable');
|
||||
throw new Error(
|
||||
'PR head repository URL is unavailable. The fork may have been deleted; '
|
||||
+ 'push the branch to a reachable repository and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -181,6 +172,20 @@ const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: st
|
||||
};
|
||||
};
|
||||
|
||||
const slugifyWorktreeName = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/').join('-')
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
};
|
||||
|
||||
interface NewWorktreeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -536,6 +541,22 @@ export function NewWorktreeDialog({
|
||||
{ sessionId: args.sessionId },
|
||||
);
|
||||
|
||||
// Record the thread this worktree session was created for, so it stays
|
||||
// visible as a context source after the opening message scrolls away.
|
||||
void sessionActions.setLinkedIssue(
|
||||
args.sessionId,
|
||||
args.directory,
|
||||
buildLinkedIssue({
|
||||
url: issueRes.issue.url,
|
||||
number: issueRes.issue.number,
|
||||
title: issueRes.issue.title,
|
||||
kind: 'issue',
|
||||
author: issueRes.issue.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
|
||||
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
|
||||
return;
|
||||
}
|
||||
@@ -576,6 +597,20 @@ export function NewWorktreeDialog({
|
||||
{ sessionId: args.sessionId },
|
||||
);
|
||||
|
||||
void sessionActions.setLinkedIssue(
|
||||
args.sessionId,
|
||||
args.directory,
|
||||
buildLinkedIssue({
|
||||
url: prContext.pr.url,
|
||||
number: prContext.pr.number,
|
||||
title: prContext.pr.title,
|
||||
kind: 'pull',
|
||||
author: prContext.pr.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
|
||||
toast.success(t('session.newWorktree.toast.sessionFromPr'));
|
||||
}
|
||||
}, [
|
||||
@@ -867,7 +902,7 @@ export function NewWorktreeDialog({
|
||||
...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}),
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args);
|
||||
|
||||
const metadata = await createWorktree(projectRef, resolvedArgs);
|
||||
@@ -1172,10 +1207,10 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
{!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="typography-small font-semibold text-foreground px-2">
|
||||
{hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
|
||||
{t('session.newWorktree.localBranches')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
||||
@@ -1204,10 +1239,10 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||
{!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="typography-small font-semibold text-foreground px-2">
|
||||
{hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
|
||||
{t('session.newWorktree.remoteBranches')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
||||
@@ -1431,10 +1466,10 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="typography-small font-semibold text-foreground px-2">
|
||||
{hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
|
||||
{t('session.newWorktree.localBranches')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
||||
@@ -1458,10 +1493,10 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="typography-small font-semibold text-foreground px-2">
|
||||
{hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
|
||||
{t('session.newWorktree.remoteBranches')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
||||
@@ -1640,10 +1675,9 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
{!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<>
|
||||
{hasExistingBranchQuery && <CommandSeparator />}
|
||||
<CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
|
||||
<CommandGroup heading={t('session.newWorktree.localBranches')}>
|
||||
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
||||
<CommandItem
|
||||
key={`local-${branch}`}
|
||||
@@ -1665,12 +1699,12 @@ export function NewWorktreeDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||
{!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
|
||||
<>
|
||||
{(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && (
|
||||
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<CommandSeparator />
|
||||
)}
|
||||
<CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
|
||||
<CommandGroup heading={t('session.newWorktree.remoteBranches')}>
|
||||
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
||||
<CommandItem
|
||||
key={`remote-${branch}`}
|
||||
@@ -1879,10 +1913,9 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<>
|
||||
{hasSourceBranchQuery && <CommandSeparator />}
|
||||
<CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
|
||||
<CommandGroup heading={t('session.newWorktree.localBranches')}>
|
||||
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
||||
<CommandItem
|
||||
key={`local-${branch}`}
|
||||
@@ -1899,12 +1932,12 @@ export function NewWorktreeDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||
{!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
|
||||
<>
|
||||
{(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && (
|
||||
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
||||
<CommandSeparator />
|
||||
)}
|
||||
<CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
|
||||
<CommandGroup heading={t('session.newWorktree.remoteBranches')}>
|
||||
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
||||
<CommandItem
|
||||
key={`remote-${branch}`}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,14 +21,17 @@ import { useI18n } from '@/lib/i18n';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import {
|
||||
deleteScheduledTask,
|
||||
deleteScheduledTaskLoopFile,
|
||||
fetchScheduledTasks,
|
||||
runScheduledTaskNow,
|
||||
setLoopScheduledTaskEnabled,
|
||||
upsertScheduledTask,
|
||||
type ScheduledTask,
|
||||
type ScheduledTaskStatus,
|
||||
} from '@/lib/scheduledTasksApi';
|
||||
import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog';
|
||||
import { canonicalizeTimezone } from '@/lib/timezones';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
|
||||
const scheduleTimes = (task: ScheduledTask): string[] => {
|
||||
const raw = Array.isArray(task.schedule.times)
|
||||
@@ -314,10 +317,11 @@ export function ScheduledTasksDialog() {
|
||||
setMutatingTaskID(task.id);
|
||||
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, enabled } : item)));
|
||||
try {
|
||||
await upsertScheduledTask(selectedProjectID, {
|
||||
...task,
|
||||
enabled,
|
||||
});
|
||||
if (task.loopFile) {
|
||||
await setLoopScheduledTaskEnabled(selectedProjectID, task.id, enabled);
|
||||
} else {
|
||||
await upsertScheduledTask(selectedProjectID, { ...task, enabled });
|
||||
}
|
||||
await reloadTasks(selectedProjectID, { silent: true });
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.updateFailed'));
|
||||
@@ -331,14 +335,20 @@ export function ScheduledTasksDialog() {
|
||||
if (!selectedProjectID) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name }));
|
||||
const confirmed = window.confirm(task.loopFile
|
||||
? t('sessions.scheduledTasks.dialog.confirm.deleteLoopFile', { taskName: task.name })
|
||||
: t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name }));
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMutatingTaskID(task.id);
|
||||
try {
|
||||
await deleteScheduledTask(selectedProjectID, task.id);
|
||||
if (task.loopFile) {
|
||||
await deleteScheduledTaskLoopFile(selectedProjectID, task.id);
|
||||
} else {
|
||||
await deleteScheduledTask(selectedProjectID, task.id);
|
||||
}
|
||||
await reloadTasks(selectedProjectID, { silent: true });
|
||||
toast.success(t('sessions.scheduledTasks.dialog.toast.deleted'));
|
||||
} catch (error) {
|
||||
@@ -348,25 +358,42 @@ export function ScheduledTasksDialog() {
|
||||
}
|
||||
}, [selectedProjectID, reloadTasks, t]);
|
||||
|
||||
const handleEditTask = React.useCallback((task: ScheduledTask) => {
|
||||
if (!task.loopFile) {
|
||||
setEditorTask(task);
|
||||
setEditorOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!selectedProject?.path) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
useFilesViewTabsStore.getState().setSelectedPath(selectedProject.path, task.loopFile, { allowOutsideRoot: true });
|
||||
useUIStore.getState().openContextFile(selectedProject.path, task.loopFile);
|
||||
}, [selectedProject?.path, setOpen]);
|
||||
|
||||
const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
|
||||
if (!selectedProjectID) {
|
||||
return;
|
||||
}
|
||||
setMutatingTaskID(task.id);
|
||||
try {
|
||||
const { sessionId } = await runScheduledTaskNow(selectedProjectID, task.id);
|
||||
const { sessionId, persistError } = await runScheduledTaskNow(selectedProjectID, task.id);
|
||||
await Promise.all([
|
||||
reloadTasks(selectedProjectID, { silent: true }),
|
||||
refreshGlobalSessions(),
|
||||
]);
|
||||
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
|
||||
if (persistError) {
|
||||
toast.warning(t('sessions.scheduledTasks.dialog.toast.startedPersistWarning'));
|
||||
} else {
|
||||
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
|
||||
}
|
||||
if (sessionId) {
|
||||
// Jump straight into the started session; selecting it also closes
|
||||
// this surface (MainLayout closes surfaces on session selection).
|
||||
const project = projects.find((entry) => entry.id === selectedProjectID);
|
||||
useSessionUIStore.getState().setCurrentSession(sessionId, project?.path ?? null);
|
||||
useUIStore.getState().setActiveMainTab('chat');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
|
||||
} finally {
|
||||
@@ -463,6 +490,14 @@ export function ScheduledTasksDialog() {
|
||||
<div className="typography-micro truncate text-muted-foreground">
|
||||
{formatSchedule(task, t)}
|
||||
</div>
|
||||
{task.loopFile ? (
|
||||
<div
|
||||
className="typography-micro truncate text-muted-foreground/70"
|
||||
title={task.loopFile}
|
||||
>
|
||||
{t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
|
||||
@@ -551,10 +586,7 @@ export function ScheduledTasksDialog() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditorTask(task);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
onClick={() => handleEditTask(task)}
|
||||
disabled={isBusy}
|
||||
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
|
||||
>
|
||||
|
||||
@@ -4,9 +4,8 @@ import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
|
||||
import { CollapsedActivityIndicator } from './sidebar/collapsedActivityIndicator';
|
||||
import type { CollapsedActivityState } from './sidebar/collapsedActivityState';
|
||||
import { CollapsedActivityIndicator } from './sidebar/sessions/collapsedActivityIndicator';
|
||||
import type { CollapsedActivityState } from './sidebar/sessions/collapsedActivityState';
|
||||
|
||||
interface SessionFolderItemProps<TSessionNode> {
|
||||
folder: SessionFolder;
|
||||
@@ -24,23 +23,7 @@ interface SessionFolderItemProps<TSessionNode> {
|
||||
onToggle: () => void;
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
renderSessionNode: (
|
||||
node: TSessionNode,
|
||||
depth?: number,
|
||||
groupDir?: string | null,
|
||||
projectId?: string | null,
|
||||
archivedBucket?: boolean,
|
||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeChildRenderExtras,
|
||||
) => React.ReactNode;
|
||||
/**
|
||||
* Returns the precomputed per-row render extras for a given node. The
|
||||
* group precomputes subtree-contains lookups once, then resolves a
|
||||
* per-node structure key here so SessionNodeItem's React.memo comparator
|
||||
* can answer with a single string compare instead of a recursive walk.
|
||||
*/
|
||||
getRenderExtras?: (node: TSessionNode) => SessionNodeRenderExtras<TSessionNode> | undefined;
|
||||
children?: React.ReactNode;
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
mobileVariant?: boolean;
|
||||
@@ -74,10 +57,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
onToggle,
|
||||
onRename,
|
||||
onDelete,
|
||||
renderSessionNode,
|
||||
getRenderExtras,
|
||||
groupDirectory,
|
||||
projectId,
|
||||
children,
|
||||
mobileVariant = false,
|
||||
alwaysShowActions = mobileVariant,
|
||||
isRenaming = false,
|
||||
@@ -97,6 +77,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
const [localDraft, setLocalDraft] = React.useState('');
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
|
||||
const renaming = isRenaming || localRenaming;
|
||||
const draft = isRenaming ? renameDraft : localDraft;
|
||||
|
||||
@@ -167,6 +148,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
|
||||
)}
|
||||
onClick={renaming ? undefined : (event) => {
|
||||
// SAFETY: this handler is attached to the div rendered directly above.
|
||||
(event.currentTarget as HTMLElement).blur();
|
||||
onToggle();
|
||||
}}
|
||||
@@ -346,9 +328,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
{subFolderItems}
|
||||
{/* Then sessions */}
|
||||
{sessions.length > 0 ? (
|
||||
sessions.map((node) =>
|
||||
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
|
||||
)
|
||||
children
|
||||
) : !subFolderItems ? (
|
||||
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
|
||||
{t('sessions.sidebar.folderItem.emptyFolder')}
|
||||
@@ -360,6 +340,9 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
);
|
||||
};
|
||||
|
||||
export const SessionFolderItem = React.memo(SessionFolderItemBase) as <TSessionNode>(
|
||||
export const SessionFolderItem = (
|
||||
/* SAFETY: React.memo preserves the generic component's props and return type. */
|
||||
React.memo(SessionFolderItemBase) as <TSessionNode>(
|
||||
props: SessionFolderItemProps<TSessionNode>,
|
||||
) => React.ReactElement;
|
||||
) => React.ReactElement
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,11 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
||||
import {
|
||||
findSwitcherItemAncestorIds,
|
||||
useSwitcherItems,
|
||||
type SwitcherItem,
|
||||
} from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
||||
@@ -22,6 +26,7 @@ import { cn } from '@/lib/utils';
|
||||
type SecondaryMeta = SwitcherItem['secondaryMeta'];
|
||||
|
||||
type SwitcherVariant = 'default' | 'compact';
|
||||
const NEW_SESSION_SWITCHER_TARGET = 'new-session';
|
||||
|
||||
type SessionSwitcherDropdownProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -40,7 +45,7 @@ export function SessionSwitcherDropdown({
|
||||
const setOpen = useUIStore((state) => state.setSessionDropdownOpen);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false} disableGlobalShortcuts>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
@@ -69,18 +74,21 @@ type SwitcherContentProps = {
|
||||
};
|
||||
|
||||
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
|
||||
const items = useSwitcherItems(true, { scopeProjectId });
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const isNewSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open === true);
|
||||
const items = useSwitcherItems(true, { scopeProjectId, currentSessionId });
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleNewSession = React.useCallback(() => {
|
||||
setActiveMainTab('chat');
|
||||
onSelect();
|
||||
openNewSessionDraft();
|
||||
}, [onSelect, openNewSessionDraft, setActiveMainTab]);
|
||||
}, [onSelect, openNewSessionDraft]);
|
||||
|
||||
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const initialFocusCompleteRef = React.useRef(false);
|
||||
const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId;
|
||||
const toggleParent = React.useCallback((sessionId: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -93,10 +101,36 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (initialFocusCompleteRef.current || !initialTarget) return;
|
||||
|
||||
const ancestorIds = initialTarget === NEW_SESSION_SWITCHER_TARGET
|
||||
? []
|
||||
: findSwitcherItemAncestorIds(items, initialTarget);
|
||||
if (!ancestorIds) return;
|
||||
|
||||
if (ancestorIds.some((id) => !expandedParents.has(id))) {
|
||||
setExpandedParents((previous) => new Set([...previous, ...ancestorIds]));
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFrame = requestAnimationFrame(() => {
|
||||
const item = Array.from(
|
||||
contentRef.current?.querySelectorAll<HTMLElement>('[data-switcher-item-id]') ?? [],
|
||||
).find((element) => element.dataset.switcherItemId === initialTarget);
|
||||
if (!item) return;
|
||||
item.focus();
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
initialFocusCompleteRef.current = true;
|
||||
});
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [expandedParents, initialTarget, items]);
|
||||
|
||||
return (
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<div ref={contentRef} className="max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-0.5">
|
||||
<BaseMenu.Item
|
||||
data-switcher-item-id={NEW_SESSION_SWITCHER_TARGET}
|
||||
onClick={handleNewSession}
|
||||
className={cn(
|
||||
'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
||||
@@ -229,6 +263,7 @@ function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isEx
|
||||
handleSelect();
|
||||
}}
|
||||
data-slot="session-switcher-item"
|
||||
data-switcher-item-id={session.id}
|
||||
className={cn(
|
||||
'group relative flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
||||
'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover',
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# Project Context Panel
|
||||
|
||||
Notes, todos, saved plans, and agent memory for the active project. Rendered by
|
||||
the `notes` surface in the desktop context rail and by the mobile workspace
|
||||
drawer.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Owns |
|
||||
|---|---|
|
||||
| `ProjectNotesTodoPanel.tsx` | container: store subscription, load, failure toast, section sidebar, search query, the todo write |
|
||||
| `NotesSection.tsx` | note composer, note list, per-note edit/pin/delete |
|
||||
| `TodosSection.tsx` | todo list, add/toggle/delete/clear, drag reorder, list resize |
|
||||
| `PlansSection.tsx` | plan list, import, pin, delete, open |
|
||||
| `MemorySection.tsx` | agent memory list, project/global scope switch, new/changed badges, edit, forget |
|
||||
| `KnowledgeCard.tsx` | the shared card shell and expand interaction every entry list uses |
|
||||
| `useProjectTodoSend.ts` | sending a todo to a current/new/worktree session |
|
||||
|
||||
## Layout
|
||||
|
||||
Content on the left, a section sidebar on the right with a drag-to-resize edge —
|
||||
the same arrangement the files surface uses, so the two panels do not disagree
|
||||
about where navigation lives. The sections were a horizontal tab strip until four of them stopped
|
||||
fitting: a strip has one line of width to divide, and each section added took
|
||||
width from the rest, while a vertical list grows downwards where there is room.
|
||||
The surface's default width matches the files surface for the same reason; at a
|
||||
third of the window the content column is too narrow to read a note in.
|
||||
|
||||
Search shares the title row rather than owning one of its own: it filters what
|
||||
is already on screen, and a full-width field read as the panel's primary control.
|
||||
It stays above both columns. Sections divide, and search is the one thing
|
||||
that division would hurt — you do not always remember whether something was
|
||||
written as a note or lives in a plan — so each sidebar entry carries its own
|
||||
match count.
|
||||
|
||||
## One card, one interaction
|
||||
|
||||
Every entry list renders `KnowledgeCard`. Notes and memories had drifted into
|
||||
two different-looking rows in the same panel — one a bare block of text opened by
|
||||
clicking the text, the other a bordered card opened by a chevron — which is the
|
||||
kind of split that makes a panel feel unfinished regardless of how either half
|
||||
behaves.
|
||||
|
||||
A collapsed card opens on a click anywhere on it. An expanded card closes only
|
||||
through its collapse action, because its body is editable and a stray click in
|
||||
the text must not throw the editor away.
|
||||
|
||||
## Plans open in place
|
||||
|
||||
Clicking a plan replaces the list with its editor, and the back control appears
|
||||
in the panel header beside the project name — PlanView titles the plan itself, so
|
||||
a title row above it would say the same thing twice. A plan belongs to the project this
|
||||
panel is about, and sending the reader to another tab to read it made them leave
|
||||
the surface they were browsing.
|
||||
|
||||
The editor is `PlanView`, lazily imported — it is a large view and most panel
|
||||
visits never open one. It scrolls itself, so the content column stops scrolling
|
||||
while a plan is open; two scrollbars for one document is what nesting them gives.
|
||||
Leaving the section or the project closes it, so its editor never sits over a
|
||||
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
|
||||
still pass `onOpenPlan` and keep theirs.
|
||||
|
||||
The panel owns the only source of truth for which project a plan belongs to,
|
||||
and it never lets the editor guess. `PlanView` receives the owner as
|
||||
`savedProjectPlan={{ projectRef, planId }}` — load and autosave both go to that
|
||||
exact project. An earlier version let the editor re-derive the project from the
|
||||
current directory, which silently opened an empty document for plans stored
|
||||
under the managed Chats owner (`openchamber:chats`), for plans opened from a
|
||||
worktree the directory lookup missed, and for plan tabs restored after a
|
||||
reload. Persisted plan tabs carry `projectPlanRef` for the same reason; a saved-plan
|
||||
tab persisted with an id but no owner is dropped on rehydrate rather than
|
||||
reopened against a guessed project. A plain session plan tab legitimately has
|
||||
neither an id nor an owner and is kept.
|
||||
|
||||
## Pins belong to one session
|
||||
|
||||
Notes and plans are project data, but attaching one writes its id to the current
|
||||
session metadata. Other sessions in the project do not inherit it. A pin made
|
||||
while a new-session draft is open lives on that draft and transfers only to the
|
||||
session created by its first message. Work status lists and detaches draft pins
|
||||
before that first message, then reads them from the created session metadata.
|
||||
|
||||
## Memory is not a fifth kind of note
|
||||
|
||||
The first four tabs hold what the user wrote. Memory holds what the **agent**
|
||||
wrote for itself, in its own store (`packages/web/server/lib/agent-memory`) and
|
||||
through its own client (`useAgentMemoryStore`). They share the panel and nothing
|
||||
else — keeping the stores apart is what stops an agent mistake from landing in
|
||||
the user's notes.
|
||||
|
||||
Two consequences shape this tab:
|
||||
|
||||
- **Entries are editable.** A memory worded badly enough to mislead should be
|
||||
fixable where it is read; deleting it and hoping the agent learns it again,
|
||||
better, is not a repair. The agent rewrites by saving the same memory again,
|
||||
so `PATCH` exists for the panel alone.
|
||||
- **Nothing gates the agent, and nothing asks the user to click.** An earlier
|
||||
version had a confirm button. It was theatre: the agent already had the
|
||||
memory whether or not the button was pressed, so the click bought the user
|
||||
nothing. Entries now carry `new` and `changed` badges derived from
|
||||
`createdAt` / `updatedAt` against a per-scope "last looked" mark, and looking
|
||||
at the tab is the acknowledgement. Nothing about review is stored server-side.
|
||||
- **The scopes are a switch, never one merged list.** A claim about the user
|
||||
reaches every project, so which store an entry sits in is the most important
|
||||
thing about it and must not be something the reader has to infer. The switch
|
||||
is a chip group, not a tab strip: it picks which store you are reading, not
|
||||
which view you are in, and the pressed state reads plainly against the
|
||||
panel background.
|
||||
|
||||
The mark is frozen while the tab is open and advanced on the way out, or every
|
||||
badge would clear the instant the tab appeared — the one moment the user is
|
||||
trying to read them. Each project keeps its own mark, so opening one project
|
||||
cannot silently clear another's badges.
|
||||
|
||||
The store is loaded by `useAgentMemorySync` in `App.tsx` and reloads on
|
||||
`openchamber:agent-memory-changed`, because the agent writes mid-turn through
|
||||
its own tool. It feeds this panel only — what a session is told about memory is
|
||||
decided server-side by `packages/web/server/lib/session-knowledge`, so it
|
||||
reaches sessions that have no UI at all and survives compaction.
|
||||
|
||||
`useProjectContextOwner` is the client authority shared by this panel and the
|
||||
memory sync. It resolves managed chat directories to the Chats root and a
|
||||
worktree to its project before either consumer touches a store. The server uses
|
||||
`agent-memory/project-resolution` for the same worktree rule. Keying by a
|
||||
worktree session directory would file memories under a project nothing reads.
|
||||
|
||||
Project memory is rendered only when the store's `projectPath` matches the
|
||||
panel owner. An owner switch hides the previous project's entries before the
|
||||
new request starts. A failed request marks the new owner unavailable instead of
|
||||
presenting that hidden list as authoritative empty memory.
|
||||
|
||||
Turning the switch back on re-reads the store only after the setting has
|
||||
finished being written. The switch flips the client immediately, which makes the
|
||||
panel ask the server straight away — and mid-write the server truthfully answers
|
||||
"disabled", which used to latch the tab hidden until a restart. Loads are also
|
||||
sequenced, so that stale answer cannot land after the good one.
|
||||
|
||||
`agentMemoryToolEnabled` is one switch for the whole feature: it removes the
|
||||
tool from the agent, this tab from the panel, and the index from new sessions.
|
||||
The tab also hides when the server reports the surface disabled, so a stale
|
||||
client cannot keep showing memory that is off. A persisted `memory` tab
|
||||
selection falls back to `notes` rather than opening a tab that no longer exists.
|
||||
|
||||
## Data flow
|
||||
|
||||
Storage is server-owned; see
|
||||
`packages/web/server/lib/project-context/DOCUMENTATION.md`. The panel never
|
||||
touches `/api/fs/*` and never handles a plan path — plans are addressed by id.
|
||||
|
||||
```
|
||||
useProjectContextStore -> ProjectNotesTodoPanel -> sections
|
||||
(server cache) (load + shared write)
|
||||
```
|
||||
|
||||
There is deliberately no cross-panel event. An earlier version broadcast
|
||||
`openchamber:project-notes-updated` / `openchamber:project-plan-saved` on the
|
||||
window and every mounted panel re-read the whole config in response. Writers now
|
||||
mutate the store and readers re-render from it.
|
||||
|
||||
## Where writes live
|
||||
|
||||
Notes, todos, and plans each have their own routes, so each section owns its
|
||||
writes end to end and no section has to persist a neighbour's state alongside
|
||||
its own. `NotesSection` and `PlansSection` call the store directly. Todos still
|
||||
route through the container only because the container already holds the list it
|
||||
sorts for display.
|
||||
|
||||
An earlier version wrote notes and todos together in one request. That forced
|
||||
the container to own the notes draft, because otherwise a todo toggle would
|
||||
persist whatever notes were last committed and discard unsaved typing. Splitting
|
||||
the routes removed the coupling rather than managing it.
|
||||
|
||||
## Layout
|
||||
|
||||
The three lists are tabs, not one stacked column. Stacking gave each list its
|
||||
own scroller inside the panel's scroller, and it only got worse as lists grew —
|
||||
the todo list had to carry a manual resize handle just to stay usable. With
|
||||
tabs there is exactly one scroller: the panel's. The resize handle and its
|
||||
persisted `todoPanelHeight` are gone with it, and each section renders its list
|
||||
at natural height.
|
||||
|
||||
The host (`RightSidebarTabs`) therefore sets `overflow-hidden`; putting a
|
||||
scroller there again would nest one inside the other.
|
||||
|
||||
Section headers no longer repeat their own name or count — the tab carries both.
|
||||
|
||||
The active tab persists in `useUIStore` so switching surfaces or remounting the
|
||||
panel returns to where the user was.
|
||||
|
||||
## Search
|
||||
|
||||
One query in the container filters all three tabs, and the tab bar doubles as
|
||||
the result summary: each tab shows its match count. Tabs divide, and search is
|
||||
the one thing division would hurt — you do not always remember whether
|
||||
something was written as a note or lives in a plan — so search deliberately
|
||||
stays above the tabs rather than becoming per-tab.
|
||||
|
||||
If the active tab has no matches and another does, the panel follows the search
|
||||
there. Without that, typing a query whose hits live elsewhere shows an empty
|
||||
list and the user has to guess which tab to try.
|
||||
|
||||
Filtering is display-only: every mutation still acts on the full list, so
|
||||
reordering or clearing completed todos while a filter is active cannot drop
|
||||
hidden items. The query resets when the project changes, since a query that
|
||||
matched the old project would silently hide everything in the new one.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Each note row keeps a local, debounced draft.** Writing on every keystroke
|
||||
would put a request behind every character, and re-reading the store each
|
||||
render would fight the caret.
|
||||
- **An external note change is adopted only while that row is untouched** since
|
||||
its last save. "Add to notes" from a chat selection must reach an open panel,
|
||||
but must never overwrite what the user is typing.
|
||||
- **Only one note is expanded at a time, and collapsed notes are clamped.**
|
||||
Notes run to 3000 characters each; with the panel owning the only scroller,
|
||||
unbounded rows turn the tab into one unbroken wall of text. A collapsed note
|
||||
shows a three-line preview and expands into its editor on click.
|
||||
- **A blanked note body is never persisted.** The server rejects it, so the row
|
||||
restores its last saved text on blur rather than showing a phantom failure.
|
||||
Deleting is an explicit action.
|
||||
- **A load failure never blanks the panel.** The store keeps the last good
|
||||
snapshot; the panel toasts once, and only when nothing had loaded yet.
|
||||
- **Completed todos sink to the bottom for display only.** Stored order is what
|
||||
the user dragged.
|
||||
- **Plan creation is not optimistic.** The id and file name come from the
|
||||
server, and a row that cannot be opened is worse than a brief wait.
|
||||
|
||||
## Pinned context
|
||||
|
||||
The pin toggle on a note or plan attaches it to the current session or draft.
|
||||
Assembly and delivery live in `packages/web/server/lib/session-knowledge`.
|
||||
|
||||
## Related
|
||||
|
||||
- Store: `packages/ui/src/stores/useProjectContextStore.ts`
|
||||
- HTTP client: `packages/ui/src/lib/projectContextApi.ts`
|
||||
- Plan viewer/editor: `packages/ui/src/components/views/PlanView.tsx`
|
||||
- User docs: `packages/docs/content/docs/notes-todos-plans.mdx`
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* One entry in any project knowledge list.
|
||||
*
|
||||
* Notes and memories drifted into two different-looking rows in the same panel:
|
||||
* one a bare block of text opened by clicking the text, the other a bordered
|
||||
* card opened by a chevron. They hold different content but they are the same
|
||||
* kind of thing to read, so they share this shell and this interaction.
|
||||
*
|
||||
* A collapsed card opens on a click anywhere on it — the whole card is the
|
||||
* target, not a chevron the user has to aim at. An expanded card closes only
|
||||
* through its collapse action, because its body is editable and a stray click
|
||||
* in the text must not throw the editor away.
|
||||
*/
|
||||
export const KnowledgeCard: React.FC<{
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
/** Shown above the body: a badge, a title, whatever the section needs. */
|
||||
header?: React.ReactNode;
|
||||
/** The preview or the editor, depending on `expanded`. */
|
||||
children: React.ReactNode;
|
||||
/** Stacked to the right, so the text keeps the full row width. */
|
||||
actions?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
expandLabel: string;
|
||||
}> = ({ expanded, onToggleExpanded, header, children, actions, footer, expandLabel }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'flex flex-col gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-1.5',
|
||||
!expanded && 'cursor-pointer hover:border-[var(--interactive-border)] hover:bg-interactive-hover/30',
|
||||
)}
|
||||
onClick={expanded ? undefined : onToggleExpanded}
|
||||
onKeyDown={expanded ? undefined : (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggleExpanded();
|
||||
}
|
||||
}}
|
||||
role={expanded ? undefined : 'button'}
|
||||
tabIndex={expanded ? undefined : 0}
|
||||
aria-label={expanded ? undefined : expandLabel}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{header}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Stopped here rather than on each control: every action is a click on
|
||||
the card too, and without this each one would also toggle it. */}
|
||||
<div
|
||||
className="flex flex-shrink-0 flex-col items-center gap-0.5"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{expanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpanded}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.notes.actions.collapse')}
|
||||
title={t('rightSidebar.contextNotesTodo.notes.actions.collapse')}
|
||||
>
|
||||
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer ? <div className="min-w-0">{footer}</div> : null}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { KnowledgeCard } from './KnowledgeCard';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { AGENT_MEMORY_BODY_MAX_LENGTH, AGENT_MEMORY_TITLE_MAX_LENGTH, type AgentMemoryEntry, type AgentMemoryScope } from '@/lib/agentMemoryApi';
|
||||
import { classifyMemory, memoryViewKey, type MemoryBadge } from '@/lib/agentMemoryBadges';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
/**
|
||||
* One stored memory.
|
||||
*
|
||||
* Read-only text on purpose: this is what the agent wrote, and the useful
|
||||
* action on someone else's claim is to remove it, not to quietly rewrite it
|
||||
* into something the agent will contradict next session.
|
||||
*
|
||||
* There is no confirm button. A badge that the user has to dismiss by hand asks
|
||||
* them to do work that tells the agent nothing — the agent already has the
|
||||
* memory either way — so the badge clears itself once they have looked.
|
||||
*/
|
||||
const MemoryRow: React.FC<{
|
||||
entry: AgentMemoryEntry;
|
||||
badge: MemoryBadge;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onSave: (patch: { title?: string; body?: string }) => void;
|
||||
onDelete: () => void;
|
||||
}> = ({ entry, badge, expanded, onToggleExpanded, onSave, onDelete }) => {
|
||||
const { t } = useI18n();
|
||||
const [titleDraft, setTitleDraft] = React.useState(entry.title);
|
||||
const [bodyDraft, setBodyDraft] = React.useState(entry.body);
|
||||
|
||||
// Adopt an external rewrite only while this row is not being edited, so the
|
||||
// agent saving mid-edit cannot swallow what the user is typing.
|
||||
React.useEffect(() => {
|
||||
if (expanded) return;
|
||||
setTitleDraft(entry.title);
|
||||
setBodyDraft(entry.body);
|
||||
}, [entry.body, entry.title, expanded]);
|
||||
|
||||
const commit = React.useCallback(() => {
|
||||
const title = titleDraft.trim();
|
||||
const body = bodyDraft.trim();
|
||||
// An emptied field is a rejected write, not a delete: restore it rather
|
||||
// than sending something the server will refuse.
|
||||
if (!title || !body) {
|
||||
setTitleDraft(entry.title);
|
||||
setBodyDraft(entry.body);
|
||||
return;
|
||||
}
|
||||
if (title === entry.title && body === entry.body) {
|
||||
return;
|
||||
}
|
||||
onSave({ title, body });
|
||||
}, [bodyDraft, entry.body, entry.title, onSave, titleDraft]);
|
||||
|
||||
const typeLabel = t(`rightSidebar.contextNotesTodo.memory.type.${entry.type}` as Parameters<typeof t>[0]);
|
||||
|
||||
return (
|
||||
<KnowledgeCard
|
||||
expanded={expanded}
|
||||
onToggleExpanded={() => {
|
||||
if (expanded) commit();
|
||||
onToggleExpanded();
|
||||
}}
|
||||
expandLabel={entry.title}
|
||||
footer={(
|
||||
<span className="flex flex-wrap items-center gap-x-2 typography-micro text-muted-foreground">
|
||||
{typeLabel}
|
||||
{entry.flagged ? (
|
||||
// Shown rather than hidden: an entry withheld from the agent is
|
||||
// exactly the one the user needs to look at.
|
||||
<span className="flex items-center gap-1 text-[var(--status-error)]">
|
||||
<Icon name="error-warning" className="h-3 w-3 flex-shrink-0" />
|
||||
{t('rightSidebar.contextNotesTodo.memory.flagged')}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
header={badge ? (
|
||||
<span
|
||||
className={cn(
|
||||
'mb-0.5 mr-1.5 inline-block rounded-full px-1.5 py-px typography-micro font-medium',
|
||||
badge === 'new'
|
||||
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
|
||||
: 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]',
|
||||
)}
|
||||
>
|
||||
{t(badge === 'new'
|
||||
? 'rightSidebar.contextNotesTodo.memory.badge.new'
|
||||
: 'rightSidebar.contextNotesTodo.memory.badge.changed')}
|
||||
</span>
|
||||
) : null}
|
||||
actions={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.memory.actions.delete')}
|
||||
title={t('rightSidebar.contextNotesTodo.memory.actions.delete')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
// Editable on purpose. A memory worded badly enough to mislead should
|
||||
// be fixable where it is read; deleting it and hoping the agent learns
|
||||
// it again, better, is not a repair.
|
||||
<div className="flex flex-col gap-1">
|
||||
<Input
|
||||
value={titleDraft}
|
||||
onChange={(event) => setTitleDraft(event.target.value.slice(0, AGENT_MEMORY_TITLE_MAX_LENGTH))}
|
||||
onBlur={commit}
|
||||
aria-label={t('rightSidebar.contextNotesTodo.memory.actions.editTitle')}
|
||||
className="h-7 typography-ui-label"
|
||||
/>
|
||||
<Textarea
|
||||
simple
|
||||
rows={Math.min(20, Math.max(3, bodyDraft.split('\n').length + 1))}
|
||||
value={bodyDraft}
|
||||
onChange={(event) => setBodyDraft(event.target.value.slice(0, AGENT_MEMORY_BODY_MAX_LENGTH))}
|
||||
onBlur={commit}
|
||||
aria-label={t('rightSidebar.contextNotesTodo.memory.actions.editBody')}
|
||||
className="min-h-0 w-full resize-none bg-transparent p-0 typography-meta leading-normal text-muted-foreground focus-visible:outline-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="block min-w-0 truncate typography-ui-label text-foreground">{entry.title}</span>
|
||||
<p className="line-clamp-2 whitespace-pre-wrap break-words typography-meta text-muted-foreground">
|
||||
{entry.body}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</KnowledgeCard>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What the agent has chosen to remember, in the two scopes it writes to.
|
||||
*
|
||||
* The scopes are a switch rather than one merged list: a claim about the user
|
||||
* reaches every project, so which store a memory sits in is the most important
|
||||
* thing about it and must never be something the reader has to infer.
|
||||
*/
|
||||
export const MemorySection: React.FC<{
|
||||
projectPath: string | null;
|
||||
query: string;
|
||||
}> = ({ projectPath, query }) => {
|
||||
const { t } = useI18n();
|
||||
const [scope, setScope] = React.useState<AgentMemoryScope>('project');
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(null);
|
||||
|
||||
const globalEntries = useAgentMemoryStore((state) => state.global);
|
||||
const projectEntries = useAgentMemoryStore((state) => selectProjectMemoryForPath(state, projectPath));
|
||||
const globalFailed = useAgentMemoryStore((state) => state.globalFailed);
|
||||
const projectFailed = useAgentMemoryStore((state) => state.projectFailed);
|
||||
const deleteEntry = useAgentMemoryStore((state) => state.deleteEntry);
|
||||
const saveEntry = useAgentMemoryStore((state) => state.saveEntry);
|
||||
const markViewed = useUIStore((state) => state.markAgentMemoryViewed);
|
||||
|
||||
const entries = scope === 'global' ? globalEntries : projectEntries;
|
||||
const scopeFailed = scope === 'global' ? globalFailed : projectFailed;
|
||||
const viewKey = memoryViewKey(scope, projectPath);
|
||||
const storedViewedAt = useUIStore((state) => state.agentMemoryViewedAt[viewKey] ?? 0);
|
||||
|
||||
/**
|
||||
* The mark is frozen for the length of the visit and only advanced on the way
|
||||
* out. Reading the live value would clear every badge the instant the tab
|
||||
* opened, which is the one moment the user is trying to read them.
|
||||
*/
|
||||
const baselineRef = React.useRef(storedViewedAt);
|
||||
const [baseline, setBaseline] = React.useState(storedViewedAt);
|
||||
React.useEffect(() => {
|
||||
baselineRef.current = useUIStore.getState().agentMemoryViewedAt[viewKey] ?? 0;
|
||||
setBaseline(baselineRef.current);
|
||||
return () => {
|
||||
markViewed(viewKey, Date.now());
|
||||
};
|
||||
}, [markViewed, viewKey]);
|
||||
|
||||
const visibleEntries = React.useMemo(
|
||||
() => entries.filter((entry) => matchesRankQuery([entry.title, entry.body], query)),
|
||||
[entries, query],
|
||||
);
|
||||
|
||||
const handleDelete = React.useCallback(async (memoryId: string) => {
|
||||
if (!await deleteEntry(scope, memoryId)) {
|
||||
const detail = useAgentMemoryStore.getState().error;
|
||||
toast.error(
|
||||
t('rightSidebar.contextNotesTodo.memory.toast.deleteFailed'),
|
||||
detail ? { description: detail } : undefined,
|
||||
);
|
||||
}
|
||||
}, [deleteEntry, scope, t]);
|
||||
|
||||
const handleSave = React.useCallback(async (memoryId: string, patch: { title?: string; body?: string }) => {
|
||||
if (!await saveEntry(scope, memoryId, patch)) {
|
||||
const detail = useAgentMemoryStore.getState().error;
|
||||
toast.error(
|
||||
t('rightSidebar.contextNotesTodo.memory.toast.saveFailed'),
|
||||
detail ? { description: detail } : undefined,
|
||||
);
|
||||
}
|
||||
}, [saveEntry, scope, t]);
|
||||
|
||||
const scopeOptions: Array<{ id: AgentMemoryScope; label: string; count: number }> = [
|
||||
{ id: 'project', label: t('rightSidebar.contextNotesTodo.memory.scope.project'), count: projectEntries.length },
|
||||
{ id: 'global', label: t('rightSidebar.contextNotesTodo.memory.scope.global'), count: globalEntries.length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Chips rather than a tab strip: these pick which store you are reading,
|
||||
not which view you are in, and the chip's pressed state says which one
|
||||
is selected far more plainly than a pill sitting on a matching
|
||||
background did. */}
|
||||
<div role="group" aria-label={t('rightSidebar.contextNotesTodo.memory.scope.label')} className="flex items-center gap-1">
|
||||
{scopeOptions.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
type="button"
|
||||
variant="chip"
|
||||
size="xs"
|
||||
aria-pressed={scope === option.id}
|
||||
className="!font-normal"
|
||||
onClick={() => setScope(option.id)}
|
||||
>
|
||||
{`${option.label} ${option.count}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{scope === 'project' && !projectPath ? (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('rightSidebar.contextNotesTodo.memory.empty.noProject')}
|
||||
</p>
|
||||
) : scopeFailed ? (
|
||||
// Said plainly rather than shown as an empty list: an empty tab would
|
||||
// read as the agent having forgotten everything it knew.
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('rightSidebar.contextNotesTodo.memory.empty.unavailable')}
|
||||
</p>
|
||||
) : visibleEntries.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{query.trim()
|
||||
? t('rightSidebar.contextNotesTodo.memory.empty.noMatches')
|
||||
: t('rightSidebar.contextNotesTodo.memory.empty.nothing')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{visibleEntries.map((entry) => (
|
||||
<MemoryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
badge={classifyMemory(entry, baseline)}
|
||||
expanded={expandedId === entry.id}
|
||||
onToggleExpanded={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||
onSave={(patch) => void handleSave(entry.id, patch)}
|
||||
onDelete={() => void handleDelete(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { KnowledgeCard } from './KnowledgeCard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { PROJECT_NOTE_BODY_MAX_LENGTH, type ProjectNote, type ProjectRef } from '@/lib/projectContextApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
const NOTE_SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
/**
|
||||
* One note, edited in place.
|
||||
*
|
||||
* The draft is local and debounced: writing straight through on every keystroke
|
||||
* would put a request behind every character, and re-reading the store on every
|
||||
* render would fight the caret. The stored body is adopted only while the
|
||||
* editor is untouched since its last save, so a concurrent write from another
|
||||
* surface reaches an idle row without eating an active one.
|
||||
*/
|
||||
const NoteRow: React.FC<{
|
||||
note: ProjectNote;
|
||||
pinned: boolean;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onSaveBody: (body: string) => void;
|
||||
onTogglePinned: () => void;
|
||||
onDelete: () => void;
|
||||
}> = ({ note, pinned, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
|
||||
const { t } = useI18n();
|
||||
const [draft, setDraft] = React.useState(note.body);
|
||||
const lastSavedRef = React.useRef(note.body);
|
||||
const debounceRef = React.useRef<number | null>(null);
|
||||
|
||||
const cancelDebounce = React.useCallback(() => {
|
||||
if (debounceRef.current !== null) {
|
||||
window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (note.body === lastSavedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (draft !== lastSavedRef.current) {
|
||||
return;
|
||||
}
|
||||
lastSavedRef.current = note.body;
|
||||
setDraft(note.body);
|
||||
}, [draft, note.body]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (draft === lastSavedRef.current) {
|
||||
return;
|
||||
}
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
// An empty body is a rejected write, not a delete. Leave it unsaved so
|
||||
// the row stays visible and the user can either restore it or delete it.
|
||||
if (!draft.trim()) {
|
||||
return;
|
||||
}
|
||||
lastSavedRef.current = draft;
|
||||
onSaveBody(draft);
|
||||
}, NOTE_SAVE_DEBOUNCE_MS);
|
||||
|
||||
return cancelDebounce;
|
||||
}, [cancelDebounce, draft, onSaveBody]);
|
||||
|
||||
React.useEffect(() => cancelDebounce, [cancelDebounce]);
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
cancelDebounce();
|
||||
if (draft === lastSavedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!draft.trim()) {
|
||||
// Restore rather than persist a blank: the server rejects it anyway.
|
||||
setDraft(lastSavedRef.current);
|
||||
return;
|
||||
}
|
||||
lastSavedRef.current = draft;
|
||||
onSaveBody(draft);
|
||||
}, [cancelDebounce, draft, onSaveBody]);
|
||||
|
||||
const sourceLabel = note.source === 'selection'
|
||||
? t('rightSidebar.contextNotesTodo.notes.source.selection')
|
||||
: note.source === 'agent'
|
||||
? t('rightSidebar.contextNotesTodo.notes.source.agent')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<KnowledgeCard
|
||||
expanded={expanded}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
expandLabel={t('rightSidebar.contextNotesTodo.notes.actions.expand')}
|
||||
footer={sourceLabel ? (
|
||||
<span className="typography-micro text-muted-foreground">{sourceLabel}</span>
|
||||
) : null}
|
||||
actions={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePinned}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
title={pinned
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
>
|
||||
{/* Filled means pinned, outline means "pin this" — the same
|
||||
language the work status panel uses. */}
|
||||
<Icon name={pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.notes.actions.delete')}
|
||||
title={t('rightSidebar.contextNotesTodo.notes.actions.delete')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{expanded ? (
|
||||
<Textarea
|
||||
simple
|
||||
autoFocus
|
||||
rows={Math.min(20, Math.max(3, draft.split('\n').length + 1))}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value.slice(0, PROJECT_NOTE_BODY_MAX_LENGTH))}
|
||||
onBlur={handleBlur}
|
||||
className="min-h-0 w-full resize-none bg-transparent p-0 typography-ui-label leading-normal text-foreground focus-visible:outline-none focus-visible:ring-0"
|
||||
/>
|
||||
) : (
|
||||
<p className="line-clamp-3 whitespace-pre-wrap break-words typography-ui-label leading-normal text-foreground" title={draft}>
|
||||
{draft}
|
||||
</p>
|
||||
)}
|
||||
</KnowledgeCard>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Free-form project notes, one entry per note.
|
||||
*
|
||||
* Notes are written through their own routes, so this section owns its writes
|
||||
* end to end — nothing here has to be persisted alongside todos.
|
||||
*/
|
||||
export const NotesSection: React.FC<{
|
||||
projectRef: ProjectRef;
|
||||
notes: ProjectNote[];
|
||||
disabled: boolean;
|
||||
query: string;
|
||||
pinnedNoteIds: ReadonlySet<string>;
|
||||
onTogglePinned: (noteId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, notes, disabled, query, pinnedNoteIds, onTogglePinned }) => {
|
||||
const { t } = useI18n();
|
||||
const [composerText, setComposerText] = React.useState('');
|
||||
// One at a time on purpose: notes can run to 3000 characters each, and
|
||||
// letting several stand open turns the tab into one unbroken wall of text.
|
||||
const [expandedNoteId, setExpandedNoteId] = React.useState<string | null>(null);
|
||||
const notesPanelHeight = useUIStore((state) => state.notesPanelHeight);
|
||||
const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
|
||||
const createNote = useProjectContextStore((state) => state.createNote);
|
||||
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
|
||||
const deleteNote = useProjectContextStore((state) => state.deleteNote);
|
||||
|
||||
const visibleNotes = React.useMemo(
|
||||
() => notes.filter((note) => matchesRankQuery([note.body], query)),
|
||||
[notes, query],
|
||||
);
|
||||
|
||||
// The store keeps the failure reason; without passing it through, every
|
||||
// failure looks identical to the user and tells them nothing about the cause.
|
||||
const reportFailure = React.useCallback((message: string) => {
|
||||
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
|
||||
toast.error(message, detail ? { description: detail } : undefined);
|
||||
}, [projectRef]);
|
||||
|
||||
const handleAdd = React.useCallback(async () => {
|
||||
const body = composerText.trim();
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
const created = await createNote(projectRef, { body });
|
||||
if (!created) {
|
||||
reportFailure(t('rightSidebar.contextNotesTodo.toast.createNoteFailed'));
|
||||
return;
|
||||
}
|
||||
setComposerText('');
|
||||
}, [composerText, createNote, projectRef, reportFailure, t]);
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
async (noteId: string) => {
|
||||
const ok = await deleteNote(projectRef, noteId);
|
||||
if (!ok) {
|
||||
reportFailure(t('rightSidebar.contextNotesTodo.toast.deleteNoteFailed'));
|
||||
}
|
||||
},
|
||||
[deleteNote, projectRef, reportFailure, t]
|
||||
);
|
||||
|
||||
const handleTogglePinned = React.useCallback(
|
||||
async (noteId: string, pinned: boolean) => {
|
||||
const ok = await onTogglePinned(noteId, pinned);
|
||||
if (!ok) {
|
||||
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
|
||||
}
|
||||
},
|
||||
[onTogglePinned, reportFailure, t]
|
||||
);
|
||||
|
||||
const handleSaveBody = React.useCallback(
|
||||
(noteId: string, body: string) => {
|
||||
void saveNoteBody(projectRef, noteId, body).then((ok: boolean) => {
|
||||
if (!ok) {
|
||||
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
|
||||
}
|
||||
});
|
||||
},
|
||||
[projectRef, reportFailure, saveNoteBody, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Counter and add live in the textarea's own footer slot: beside it they
|
||||
cost width the panel does not have and leave the button floating
|
||||
against a tall field. */}
|
||||
<Textarea
|
||||
value={composerText}
|
||||
onChange={(event) => setComposerText(event.target.value.slice(0, PROJECT_NOTE_BODY_MAX_LENGTH))}
|
||||
placeholder={t('rightSidebar.contextNotesTodo.notes.placeholder')}
|
||||
resizedHeight={notesPanelHeight}
|
||||
onResizeHeightChange={setNotesPanelHeight}
|
||||
useScrollShadow
|
||||
scrollShadowSize={56}
|
||||
disabled={disabled}
|
||||
endSlot={(
|
||||
<>
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{composerText.length}/{PROJECT_NOTE_BODY_MAX_LENGTH}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={disabled || composerText.trim().length === 0}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.notes.addAria')}
|
||||
title={t('rightSidebar.contextNotesTodo.notes.addAria')}
|
||||
>
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* No frame around the list: each note is a bordered card, and an outer
|
||||
border sitting flush against them read as lines joining the cards. */}
|
||||
<div>
|
||||
{visibleNotes.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{query.trim()
|
||||
? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() })
|
||||
: t('rightSidebar.contextNotesTodo.notes.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{visibleNotes.map((note) => (
|
||||
<NoteRow
|
||||
key={note.id}
|
||||
note={note}
|
||||
pinned={pinnedNoteIds.has(note.id)}
|
||||
expanded={expandedNoteId === note.id}
|
||||
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
|
||||
onSaveBody={(body) => handleSaveBody(note.id, body)}
|
||||
onTogglePinned={() => void handleTogglePinned(note.id, !pinnedNoteIds.has(note.id))}
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { requestFileAccess } from '@/lib/desktop';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { parsePlanMarkdown, resolveProjectContextId, type ProjectPlanLink, type ProjectRef } from '@/lib/projectContextApi';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
/**
|
||||
* Saved plan markdown for the project.
|
||||
*
|
||||
* Plan mutations touch neither notes nor todos, so this section talks to the
|
||||
* store directly instead of routing writes through the container.
|
||||
*/
|
||||
export const PlansSection: React.FC<{
|
||||
projectRef: ProjectRef;
|
||||
plans: ProjectPlanLink[];
|
||||
/** Panel-wide filter, matched against plan titles. */
|
||||
query: string;
|
||||
/** Hosts without a ContextPanel (mobile) render their own plan viewer. The
|
||||
plan carries its owner so the host viewer never guesses the project. */
|
||||
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
pinnedPlanIds: ReadonlySet<string>;
|
||||
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isImporting, setIsImporting] = React.useState(false);
|
||||
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
|
||||
const createPlan = useProjectContextStore((state) => state.createPlan);
|
||||
const removePlan = useProjectContextStore((state) => state.deletePlan);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
|
||||
const handleDeletePlan = React.useCallback(
|
||||
async (planId: string) => {
|
||||
if (deletingPlanId) {
|
||||
return;
|
||||
}
|
||||
setDeletingPlanId(planId);
|
||||
try {
|
||||
const ok = await removePlan(projectRef, planId);
|
||||
if (!ok) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.deletePlanFailed'));
|
||||
}
|
||||
} finally {
|
||||
setDeletingPlanId(null);
|
||||
}
|
||||
},
|
||||
[deletingPlanId, projectRef, removePlan, t]
|
||||
);
|
||||
|
||||
// Imported files arrive as a whole markdown document; split it the same way
|
||||
// the server would so the stored plan keeps the author's heading.
|
||||
const importPlanFromText = React.useCallback(
|
||||
async (text: string, fallbackTitle: string) => {
|
||||
if (!text.trim()) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty'));
|
||||
return;
|
||||
}
|
||||
const parsed = parsePlanMarkdown(text, fallbackTitle || t('rightSidebar.contextNotesTodo.plan.defaultTitle'));
|
||||
const created = await createPlan(projectRef, { title: parsed.title, body: parsed.body });
|
||||
if (!created) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed'));
|
||||
return;
|
||||
}
|
||||
toast.success(t('rightSidebar.contextNotesTodo.toast.planImported'));
|
||||
},
|
||||
[createPlan, projectRef, t]
|
||||
);
|
||||
|
||||
const handleTriggerImport = React.useCallback(async () => {
|
||||
if (isImporting) {
|
||||
return;
|
||||
}
|
||||
const result = await requestFileAccess({
|
||||
defaultPath: projectRef.path,
|
||||
filters: [
|
||||
{ name: 'Plan files', extensions: ['md', 'markdown', 'txt'] },
|
||||
{ name: 'All files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (result.success && result.path) {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ path: result.path, allowOutsideWorkspace: 'true' });
|
||||
if (result.outsideFileGrant) {
|
||||
params.set('outsideFileGrant', result.outsideFileGrant);
|
||||
}
|
||||
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'));
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
const fallbackTitle = result.path.split('/').pop()?.replace(/\.(md|markdown|txt)$/i, '').trim() || '';
|
||||
await importPlanFromText(text, fallbackTitle);
|
||||
} catch (error) {
|
||||
const description = error instanceof Error ? error.message : undefined;
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error === 'Native file picker not available') {
|
||||
// Fall back to the HTML file input for web/non-desktop runtimes.
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}, [importPlanFromText, isImporting, projectRef.path, t]);
|
||||
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File | null) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim();
|
||||
await importPlanFromText(text, fallbackTitle);
|
||||
} catch (error) {
|
||||
const description = error instanceof Error ? error.message : undefined;
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[importPlanFromText, t]
|
||||
);
|
||||
|
||||
const handleTogglePinned = React.useCallback(
|
||||
async (planId: string, pinned: boolean) => {
|
||||
const ok = await onTogglePinned(planId, pinned);
|
||||
if (!ok) {
|
||||
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
|
||||
}
|
||||
},
|
||||
[onTogglePinned, projectRef, t]
|
||||
);
|
||||
|
||||
const visiblePlans = React.useMemo(
|
||||
() => plans.filter((plan) => matchesRankQuery([plan.title], query)),
|
||||
[plans, query],
|
||||
);
|
||||
|
||||
const handleOpenPlan = React.useCallback(
|
||||
(plan: ProjectPlanLink) => {
|
||||
if (onOpenPlan) {
|
||||
onOpenPlan({ id: plan.id, title: plan.title, projectRef });
|
||||
return;
|
||||
}
|
||||
const panelDirectory = currentDirectory?.trim() || projectRef.path.trim();
|
||||
if (!panelDirectory) {
|
||||
return;
|
||||
}
|
||||
openContextPanelTab(panelDirectory, {
|
||||
mode: 'plan',
|
||||
projectPlanId: plan.id,
|
||||
projectPlanRef: projectRef,
|
||||
// Storage identity is derived from the project path, not the settings
|
||||
// id, so the tab identity uses the same derivation. Two projects
|
||||
// sharing a settings id but not a path must not merge plan tabs.
|
||||
dedupeKey: `plan:${resolveProjectContextId(projectRef)}:${plan.id}`,
|
||||
label: plan.title,
|
||||
});
|
||||
},
|
||||
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,.markdown,.txt,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
void handleUploadFile(file);
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTriggerImport}
|
||||
disabled={isImporting}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
|
||||
title={t('rightSidebar.contextNotesTodo.plans.importFromFile')}
|
||||
>
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-background/40">
|
||||
{visiblePlans.length === 0 ? (
|
||||
<p className="px-3 py-3 typography-meta text-muted-foreground">
|
||||
{query.trim()
|
||||
? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() })
|
||||
: t('rightSidebar.contextNotesTodo.plans.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/50">
|
||||
{visiblePlans.map((plan) => (
|
||||
<li key={plan.id} className="flex items-center gap-1.5 px-2.5 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenPlan(plan)}
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<span className="min-w-0 truncate typography-ui-label text-foreground">{plan.title}</span>
|
||||
<span className="flex-shrink-0 typography-micro text-muted-foreground">
|
||||
{new Date(plan.createdAt).toLocaleDateString(getCurrentIntlLocale())}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
pinnedPlanIds.has(plan.id) ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={pinnedPlanIds.has(plan.id)}
|
||||
aria-label={pinnedPlanIds.has(plan.id)
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
title={pinnedPlanIds.has(plan.id)
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
>
|
||||
<Icon name="pushpin" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeletePlan(plan.id)}
|
||||
disabled={deletingPlanId === plan.id}
|
||||
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={t('rightSidebar.contextNotesTodo.plans.deletePlan')}
|
||||
aria-label={t('rightSidebar.contextNotesTodo.plans.deletePlanWithTitle', { title: plan.title })}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,575 @@
|
||||
import React from 'react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { resolveProjectContextId, type ProjectRef, type ProjectTodoItem } from '@/lib/projectContextApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { selectProjectMemoryForPath, useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { countHighlightedMemories, memoryViewKey } from '@/lib/agentMemoryBadges';
|
||||
import { EMPTY_PROJECT_CONTEXT_ENTRY, useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { TodoSendDialog } from '../TodoSendDialog';
|
||||
import { MemorySection } from './MemorySection';
|
||||
import { NotesSection } from './NotesSection';
|
||||
import { PlansSection } from './PlansSection';
|
||||
import { TodosSection } from './TodosSection';
|
||||
import { useProjectTodoSend } from './useProjectTodoSend';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
/** Lazy: the plan editor is a large view, and most panel visits never open it. */
|
||||
const PlanView = React.lazy(() => import('@/components/views/PlanView').then((module) => ({ default: module.PlanView })));
|
||||
|
||||
interface ProjectNotesTodoPanelProps {
|
||||
projectRef: ProjectRef | null;
|
||||
projectLabel?: string | null;
|
||||
canCreateWorktree?: boolean;
|
||||
onActionComplete?: () => void;
|
||||
/** When provided, opening a plan calls this instead of the desktop context
|
||||
panel tab — hosts without ContextPanel (mobile) render their own viewer.
|
||||
The plan carries its owner so the host's viewer cannot guess wrong. */
|
||||
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type ProjectContextTab = 'notes' | 'todos' | 'plans' | 'memory';
|
||||
|
||||
const TAB_ORDER: ProjectContextTab[] = ['notes', 'todos', 'plans', 'memory'];
|
||||
|
||||
/** Wide enough for the longest section label, narrow enough to leave the
|
||||
content column usable in a half-width panel. */
|
||||
const SIDEBAR_MIN_WIDTH = 120;
|
||||
const SIDEBAR_MAX_WIDTH = 320;
|
||||
|
||||
const clampSidebarWidth = (width: number): number => (
|
||||
Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width)))
|
||||
);
|
||||
|
||||
const sortTodosWithCompletedLast = (items: ProjectTodoItem[]): ProjectTodoItem[] => [
|
||||
...items.filter((todo) => !todo.completed),
|
||||
...items.filter((todo) => todo.completed),
|
||||
];
|
||||
|
||||
const matches = (haystack: string, needle: string): boolean => (
|
||||
haystack.toLowerCase().includes(needle)
|
||||
);
|
||||
|
||||
/**
|
||||
* Notes, todos, and plans for the active project.
|
||||
*
|
||||
* The three lists are tabs rather than one stacked column: stacking gave each
|
||||
* list its own scroller inside the panel's scroller, which only got worse as
|
||||
* lists grew and forced the todo list to carry a manual resize handle just to
|
||||
* stay usable.
|
||||
*
|
||||
* Search sits above the tabs and stays panel-wide. Tabs divide, and search is
|
||||
* the one thing that division would hurt — you do not always remember whether
|
||||
* something was written as a note or lives in a plan — so the tab bar doubles
|
||||
* as the result summary by showing per-tab match counts.
|
||||
*
|
||||
* Storage is server-owned and reached through `useProjectContextStore`.
|
||||
*/
|
||||
export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
projectRef,
|
||||
projectLabel,
|
||||
canCreateWorktree = false,
|
||||
onActionComplete,
|
||||
onOpenPlan,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const projectContextId = React.useMemo(() => resolveProjectContextId(projectRef), [projectRef]);
|
||||
const contextEntry = useProjectContextStore(
|
||||
(state) => (projectContextId ? state.entries[projectContextId] : undefined) ?? EMPTY_PROJECT_CONTEXT_ENTRY,
|
||||
);
|
||||
const loadProjectContext = useProjectContextStore((state) => state.load);
|
||||
const saveTodos = useProjectContextStore((state) => state.saveTodos);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
|
||||
const [sessionPins, setSessionPins] = React.useState<SessionProjectContextPins>({ notes: [], plans: [] });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (newSessionDraft.open) {
|
||||
setSessionPins(newSessionDraft.projectContextPins ?? { notes: [], plans: [] });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetchSessionKnowledgeSummary(currentSessionDirectory, currentSessionId).then((summary) => {
|
||||
if (!cancelled) {
|
||||
setSessionPins({
|
||||
notes: summary.notes.map((note) => note.id),
|
||||
plans: summary.plans.map((plan) => plan.id),
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, newSessionDraft.projectContextPins]);
|
||||
|
||||
const toggleSessionPin = React.useCallback(async (kind: 'note' | 'plan', id: string, pinned: boolean) => {
|
||||
if (newSessionDraft.open) {
|
||||
setDraftProjectContextPin(kind, id, pinned);
|
||||
return true;
|
||||
}
|
||||
if (!currentSessionId || !currentSessionDirectory) return false;
|
||||
const next = await setSessionProjectContextPin(currentSessionDirectory, currentSessionId, kind, id, pinned);
|
||||
if (!next) return false;
|
||||
setSessionPins(next);
|
||||
return true;
|
||||
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, setDraftProjectContextPin]);
|
||||
|
||||
const pinnedNoteIds = React.useMemo(() => new Set(sessionPins.notes), [sessionPins.notes]);
|
||||
const pinnedPlanIds = React.useMemo(() => new Set(sessionPins.plans), [sessionPins.plans]);
|
||||
|
||||
// The whole feature is one switch: with memory off there is nothing for the
|
||||
// agent to manage, so showing the user what is stored would be pointless.
|
||||
const memoryEnabled = useUIStore((state) => (
|
||||
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
|
||||
));
|
||||
const memoryDisabledByServer = useAgentMemoryStore((state) => state.disabled);
|
||||
const memoryVisible = memoryEnabled && !memoryDisabledByServer;
|
||||
const globalMemory = useAgentMemoryStore((state) => state.global);
|
||||
const projectMemory = useAgentMemoryStore(
|
||||
(state) => selectProjectMemoryForPath(state, projectRef?.path ?? null),
|
||||
);
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const storedTab = useUIStore((state) => state.projectContextTab);
|
||||
const setStoredTab = useUIStore((state) => state.setProjectContextTab);
|
||||
const requestedTab = TAB_ORDER.includes(storedTab as ProjectContextTab)
|
||||
? storedTab as ProjectContextTab
|
||||
: 'notes';
|
||||
// A persisted 'memory' must not survive the feature being turned off, or the
|
||||
// panel would open on a tab that no longer exists.
|
||||
const activeTab: ProjectContextTab = requestedTab === 'memory' && !memoryVisible
|
||||
? 'notes'
|
||||
: requestedTab;
|
||||
|
||||
const [query, setQuery] = React.useState('');
|
||||
/**
|
||||
* The plan being read, shown in place of the list. Plans used to open as a
|
||||
* separate context-panel tab, which pushed the user out of the panel they
|
||||
* were browsing to read something that belongs to it.
|
||||
*/
|
||||
const [openPlan, setOpenPlan] = React.useState<{ id: string; title: string } | null>(null);
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
|
||||
// Completed items sink to the bottom in the list; storage order is untouched.
|
||||
const todos = React.useMemo(
|
||||
() => sortTodosWithCompletedLast(contextEntry.todos),
|
||||
[contextEntry.todos],
|
||||
);
|
||||
const isLoading = contextEntry.loading && !contextEntry.loaded;
|
||||
|
||||
const memoryEntries = React.useMemo(
|
||||
() => [...globalMemory, ...projectMemory],
|
||||
[globalMemory, projectMemory],
|
||||
);
|
||||
|
||||
const counts = React.useMemo(() => {
|
||||
if (!trimmedQuery) {
|
||||
return {
|
||||
notes: contextEntry.notes.length,
|
||||
todos: todos.length,
|
||||
plans: contextEntry.plans.length,
|
||||
memory: memoryEntries.length,
|
||||
};
|
||||
}
|
||||
return {
|
||||
notes: contextEntry.notes.filter((note) => matches(note.body, trimmedQuery)).length,
|
||||
todos: todos.filter((todo) => matches(todo.text, trimmedQuery)).length,
|
||||
plans: contextEntry.plans.filter((plan) => matches(plan.title, trimmedQuery)).length,
|
||||
memory: memoryEntries.filter((entry) => (
|
||||
matches(entry.title, trimmedQuery) || matches(entry.body, trimmedQuery)
|
||||
)).length,
|
||||
};
|
||||
}, [contextEntry.notes, contextEntry.plans, memoryEntries, todos, trimmedQuery]);
|
||||
|
||||
// Counted across both scopes against their own marks: a new global memory is
|
||||
// the one the user most needs to see, and it would be invisible behind the
|
||||
// project scope.
|
||||
const globalViewedAt = useUIStore((state) => state.agentMemoryViewedAt[memoryViewKey('global', null)] ?? 0);
|
||||
const projectViewedAt = useUIStore(
|
||||
(state) => state.agentMemoryViewedAt[memoryViewKey('project', projectRef?.path ?? null)] ?? 0,
|
||||
);
|
||||
const highlightedMemoryCount = React.useMemo(
|
||||
() => countHighlightedMemories(globalMemory, globalViewedAt)
|
||||
+ countHighlightedMemories(projectMemory, projectViewedAt),
|
||||
[globalMemory, globalViewedAt, projectMemory, projectViewedAt],
|
||||
);
|
||||
|
||||
const storedSidebarWidth = useUIStore((state) => state.projectContextSidebarWidth);
|
||||
const setSidebarWidth = useUIStore((state) => state.setProjectContextSidebarWidth);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
// Held locally while dragging so every pointer move does not write through
|
||||
// the persisted store, then committed once on release.
|
||||
const [draggedWidth, setDraggedWidth] = React.useState<number | null>(null);
|
||||
const sidebarWidth = clampSidebarWidth(draggedWidth ?? storedSidebarWidth);
|
||||
|
||||
const handleResizeStart = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setIsResizing(true);
|
||||
setDraggedWidth(sidebarWidth);
|
||||
}, [sidebarWidth]);
|
||||
|
||||
const handleResizeMove = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
return;
|
||||
}
|
||||
// The sidebar is on the right, so dragging its left edge leftwards widens
|
||||
// it: the width is the distance from the pointer to the panel's edge.
|
||||
const panelRight = event.currentTarget.closest('nav')?.getBoundingClientRect().right ?? 0;
|
||||
setDraggedWidth(clampSidebarWidth(panelRight - event.clientX));
|
||||
}, []);
|
||||
|
||||
const handleResizeEnd = React.useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
setIsResizing(false);
|
||||
setDraggedWidth((current) => {
|
||||
if (current !== null) {
|
||||
setSidebarWidth(clampSidebarWidth(current));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, [setSidebarWidth]);
|
||||
|
||||
const send = useProjectTodoSend({ projectRef, canCreateWorktree, onActionComplete });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectRef) {
|
||||
return;
|
||||
}
|
||||
void loadProjectContext(projectRef);
|
||||
}, [loadProjectContext, projectRef]);
|
||||
|
||||
// Surface a load failure once. The store keeps whatever it already had, so
|
||||
// the panel never blanks out over an unreachable server.
|
||||
const reportedErrorRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!contextEntry.error) {
|
||||
reportedErrorRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (reportedErrorRef.current === contextEntry.error) {
|
||||
return;
|
||||
}
|
||||
reportedErrorRef.current = contextEntry.error;
|
||||
if (!contextEntry.loaded) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.loadNotesFailed'));
|
||||
}
|
||||
}, [contextEntry.error, contextEntry.loaded, t]);
|
||||
|
||||
// A plan belongs to its project and to its section; leaving either must not
|
||||
// leave its editor open over a list it no longer matches.
|
||||
React.useEffect(() => {
|
||||
setOpenPlan(null);
|
||||
}, [projectContextId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeTab !== 'plans') {
|
||||
setOpenPlan(null);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Reset the filter when the project changes: a query that matched the old
|
||||
// project would silently hide everything in the new one.
|
||||
React.useEffect(() => {
|
||||
setQuery('');
|
||||
}, [projectContextId]);
|
||||
|
||||
// Follow the search to where the matches are. Without this, typing a query
|
||||
// whose hits are all in another tab shows an empty list and the user has to
|
||||
// guess which tab to try. Only moves off a tab that has nothing.
|
||||
React.useEffect(() => {
|
||||
if (!trimmedQuery || counts[activeTab] > 0) {
|
||||
return;
|
||||
}
|
||||
const withMatches = TAB_ORDER.find((tab) => counts[tab] > 0);
|
||||
if (withMatches) {
|
||||
setStoredTab(withMatches);
|
||||
}
|
||||
}, [activeTab, counts, setStoredTab, trimmedQuery]);
|
||||
|
||||
const handlePersistTodos = React.useCallback(
|
||||
(nextTodos: ProjectTodoItem[]) => {
|
||||
if (!projectRef) {
|
||||
return;
|
||||
}
|
||||
// The store owns per-project write serialization and rollback; the panel
|
||||
// only decides what to persist and how to report a failure.
|
||||
void saveTodos(projectRef, nextTodos).then((saved) => {
|
||||
if (!saved) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
|
||||
}
|
||||
});
|
||||
},
|
||||
[projectRef, saveTodos, t]
|
||||
);
|
||||
|
||||
/**
|
||||
* The sidebar entries. Icons are worth their width here: a vertical list has
|
||||
* the room a horizontal strip did not, and they make the sections scannable
|
||||
* without reading.
|
||||
*/
|
||||
const sections: Array<{ id: ProjectContextTab; icon: IconName; label: string; count: string }> = React.useMemo(() => ([
|
||||
{
|
||||
id: 'notes',
|
||||
icon: 'sticky-note',
|
||||
label: t('rightSidebar.contextNotesTodo.tabs.notes'),
|
||||
count: String(counts.notes),
|
||||
},
|
||||
{
|
||||
id: 'todos',
|
||||
icon: 'checkbox-circle',
|
||||
label: t('rightSidebar.contextNotesTodo.tabs.todos'),
|
||||
count: String(counts.todos),
|
||||
},
|
||||
{
|
||||
id: 'plans',
|
||||
icon: 'file-text',
|
||||
label: t('rightSidebar.contextNotesTodo.tabs.plans'),
|
||||
count: String(counts.plans),
|
||||
},
|
||||
...(memoryVisible ? [{
|
||||
id: 'memory' as const,
|
||||
icon: 'brain-4' as IconName,
|
||||
label: t('rightSidebar.contextNotesTodo.tabs.memory'),
|
||||
// The new/changed count replaces the total when there is anything the
|
||||
// user has not seen: what the agent stored without asking is the number
|
||||
// that deserves the glance.
|
||||
count: highlightedMemoryCount > 0
|
||||
? `${highlightedMemoryCount}/${counts.memory}`
|
||||
: String(counts.memory),
|
||||
}] : []),
|
||||
]), [counts, highlightedMemoryCount, memoryVisible, t]);
|
||||
|
||||
if (!projectRef) {
|
||||
return (
|
||||
<div className={cn('w-full min-w-0 p-3', className)}>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('rightSidebar.contextNotesTodo.empty.selectProject')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const projectTitle = projectLabel?.trim()
|
||||
|| projectRef.path.split('/').filter(Boolean).pop()
|
||||
|| projectRef.path;
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full min-h-0 w-full min-w-0 flex-col', className)}>
|
||||
{/* Title and search share a row: search is a filter over what is already
|
||||
on screen, not a heading, and a full-width field read as the panel's
|
||||
primary control. */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2 p-3 pb-2">
|
||||
{/* Back sits here, beside the project name, rather than above the
|
||||
editor: PlanView already titles the plan, and a second title row
|
||||
said the same thing twice. */}
|
||||
{openPlan ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenPlan(null)}
|
||||
className="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.plans.actions.back')}
|
||||
title={t('rightSidebar.contextNotesTodo.plans.actions.back')}
|
||||
>
|
||||
<Icon name="arrow-left-s" className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<h3
|
||||
className="min-w-0 flex-1 truncate typography-ui-label font-semibold text-foreground"
|
||||
title={projectRef.path}
|
||||
>
|
||||
{projectTitle}
|
||||
</h3>
|
||||
|
||||
<div className="relative w-40 flex-shrink-0">
|
||||
<Icon
|
||||
name="search"
|
||||
className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('rightSidebar.contextNotesTodo.search.placeholder')}
|
||||
className="h-8 pl-7 pr-7"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute right-1.5 top-1/2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.search.clear')}
|
||||
title={t('rightSidebar.contextNotesTodo.search.clear')}
|
||||
>
|
||||
<Icon name="close" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Mobile: a half-width panel has no room for a side column, so the
|
||||
sections become the same pill strip the mobile drawer's surface
|
||||
tabs use — the active pill carries the label, the rest collapse to
|
||||
icon and count. */}
|
||||
{isMobile ? (
|
||||
<nav
|
||||
className="flex flex-shrink-0 items-center gap-1.5 overflow-x-auto px-3 pb-2"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.sections.label')}
|
||||
>
|
||||
{sections.map((section) => {
|
||||
const isActive = activeTab === section.id;
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => setStoredTab(section.id)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
isActive
|
||||
? 'border-transparent bg-interactive-active text-foreground'
|
||||
: 'border-[var(--interactive-border)] text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon name={section.icon} className="h-4 w-4 flex-shrink-0" />
|
||||
{isActive ? (
|
||||
<span className="whitespace-nowrap typography-meta">{section.label}</span>
|
||||
) : null}
|
||||
<span className="typography-micro text-muted-foreground">{section.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
{/* Content first, sidebar on the right — the same order and the same
|
||||
drag-to-resize edge the files surface uses, so the two panels do not
|
||||
disagree about where navigation lives. */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* The plan editor scrolls itself; nesting it in this scroller would
|
||||
give the panel two scrollbars for one document. */}
|
||||
<div className={cn('min-h-0 min-w-0 flex-1 p-3', openPlan ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||
{activeTab === 'notes' ? (
|
||||
<NotesSection
|
||||
projectRef={projectRef}
|
||||
notes={contextEntry.notes}
|
||||
disabled={isLoading}
|
||||
query={query}
|
||||
pinnedNoteIds={pinnedNoteIds}
|
||||
onTogglePinned={(noteId, pinned) => toggleSessionPin('note', noteId, pinned)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'todos' ? (
|
||||
<TodosSection
|
||||
todos={todos}
|
||||
query={query}
|
||||
disabled={isLoading}
|
||||
canCreateWorktree={canCreateWorktree}
|
||||
sendingTodoId={send.sendingTodoId}
|
||||
onPersistTodos={handlePersistTodos}
|
||||
onSendToCurrentSession={send.sendToCurrentSession}
|
||||
onSendToNewSession={send.sendToNewSession}
|
||||
onSendToNewWorktreeSession={send.sendToNewWorktreeSession}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'memory' && memoryVisible ? (
|
||||
<MemorySection projectPath={projectRef.path} query={query} />
|
||||
) : null}
|
||||
|
||||
{activeTab === 'plans' && !openPlan ? (
|
||||
<PlansSection
|
||||
projectRef={projectRef}
|
||||
plans={contextEntry.plans}
|
||||
query={query}
|
||||
pinnedPlanIds={pinnedPlanIds}
|
||||
onTogglePinned={(planId, pinned) => toggleSessionPin('plan', planId, pinned)}
|
||||
// Hosts that own a fullscreen plan surface (mobile) keep it; on the
|
||||
// desktop panel the plan opens here, in place of the list.
|
||||
onOpenPlan={onOpenPlan ?? setOpenPlan}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'plans' && openPlan && projectRef ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<PlanView
|
||||
savedProjectPlan={{ projectRef, planId: openPlan.id }}
|
||||
onNavigatedToChat={() => setOpenPlan(null)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isMobile ? null : (
|
||||
<nav
|
||||
className="relative flex flex-shrink-0 flex-col gap-0.5 overflow-y-auto border-l border-[var(--interactive-border)] p-2"
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
aria-label={t('rightSidebar.contextNotesTodo.sections.label')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
|
||||
isResizing && 'bg-[var(--interactive-border)]',
|
||||
)}
|
||||
onPointerDown={handleResizeStart}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
onPointerCancel={handleResizeEnd}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.sections.resize')}
|
||||
/>
|
||||
{sections.map((section) => {
|
||||
const isActive = activeTab === section.id;
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => setStoredTab(section.id)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
isActive
|
||||
? 'bg-interactive-active text-foreground'
|
||||
: 'text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground',
|
||||
)}
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<Icon name={section.icon} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate typography-meta">{section.label}</span>
|
||||
<span className="flex-shrink-0 typography-micro text-muted-foreground">{section.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TodoSendDialog
|
||||
open={send.pendingSendTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
send.closeDialog();
|
||||
}
|
||||
}}
|
||||
target={send.pendingSendTarget?.kind ?? 'session'}
|
||||
projectDirectory={projectRef.path}
|
||||
submitting={send.isSubmitting}
|
||||
onConfirm={send.confirmSend}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
|
||||
import { CSS as DndCSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { PROJECT_TODO_TEXT_MAX_LENGTH, type ProjectTodoItem } from '@/lib/projectContextApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const createTodoId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `todo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
const sortTodosWithCompletedLast = (items: ProjectTodoItem[]): ProjectTodoItem[] => [
|
||||
...items.filter((todo) => !todo.completed),
|
||||
...items.filter((todo) => todo.completed),
|
||||
];
|
||||
|
||||
const insertTodoBeforeCompleted = (items: ProjectTodoItem[], item: ProjectTodoItem): ProjectTodoItem[] => {
|
||||
const firstCompletedIndex = items.findIndex((todo) => todo.completed);
|
||||
if (firstCompletedIndex === -1) {
|
||||
return [...items, item];
|
||||
}
|
||||
return [...items.slice(0, firstCompletedIndex), item, ...items.slice(firstCompletedIndex)];
|
||||
};
|
||||
|
||||
type SortableTodoHandleProps = {
|
||||
attributes: ReturnType<typeof useSortable>['attributes'];
|
||||
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
const SortableTodoItem: React.FC<{
|
||||
id: string;
|
||||
children: (dragHandleProps: SortableTodoHandleProps) => React.ReactNode;
|
||||
}> = ({ id, children }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: DndCSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
className={cn(isDragging && 'opacity-60')}
|
||||
>
|
||||
{children({ attributes, listeners, setActivatorNodeRef, isDragging })}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const TodosSection: React.FC<{
|
||||
todos: ProjectTodoItem[];
|
||||
/** Panel-wide filter. Mutations still act on the full list. */
|
||||
query: string;
|
||||
disabled: boolean;
|
||||
canCreateWorktree: boolean;
|
||||
sendingTodoId: string | null;
|
||||
/** Persists the whole list through the container's store write. */
|
||||
onPersistTodos: (next: ProjectTodoItem[]) => void;
|
||||
onSendToCurrentSession: (todoText: string) => void;
|
||||
onSendToNewSession: (todoId: string, todoText: string) => void;
|
||||
onSendToNewWorktreeSession: (todoId: string, todoText: string) => void;
|
||||
}> = ({
|
||||
todos,
|
||||
query,
|
||||
disabled,
|
||||
canCreateWorktree,
|
||||
sendingTodoId,
|
||||
onPersistTodos,
|
||||
onSendToCurrentSession,
|
||||
onSendToNewSession,
|
||||
onSendToNewWorktreeSession,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newTodoText, setNewTodoText] = React.useState('');
|
||||
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
|
||||
|
||||
const handleAddTodo = React.useCallback(() => {
|
||||
const trimmed = newTodoText.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
onPersistTodos(insertTodoBeforeCompleted(todos, {
|
||||
id: createTodoId(),
|
||||
text: trimmed.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH),
|
||||
completed: false,
|
||||
createdAt: Date.now(),
|
||||
}));
|
||||
setNewTodoText('');
|
||||
}, [newTodoText, onPersistTodos, todos]);
|
||||
|
||||
const handleToggleTodoExpanded = React.useCallback((id: string) => {
|
||||
setExpandedTodoIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleTodo = React.useCallback(
|
||||
(id: string, completed: boolean) => {
|
||||
const todo = todos.find((item) => item.id === id);
|
||||
if (!todo || todo.completed === completed) {
|
||||
return;
|
||||
}
|
||||
const remaining = todos.filter((item) => item.id !== id);
|
||||
const updated = { ...todo, completed };
|
||||
onPersistTodos(completed ? [...remaining, updated] : insertTodoBeforeCompleted(remaining, updated));
|
||||
},
|
||||
[onPersistTodos, todos]
|
||||
);
|
||||
|
||||
const handleDeleteTodo = React.useCallback(
|
||||
(id: string) => {
|
||||
onPersistTodos(todos.filter((todo) => todo.id !== id));
|
||||
},
|
||||
[onPersistTodos, todos]
|
||||
);
|
||||
|
||||
const handleClearCompletedTodos = React.useCallback(() => {
|
||||
const next = todos.filter((todo) => !todo.completed);
|
||||
if (next.length === todos.length) {
|
||||
return;
|
||||
}
|
||||
onPersistTodos(next);
|
||||
}, [onPersistTodos, todos]);
|
||||
|
||||
const handleTodoReorder = React.useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const oldIndex = todos.findIndex((todo) => todo.id === active.id);
|
||||
const newIndex = todos.findIndex((todo) => todo.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
return;
|
||||
}
|
||||
onPersistTodos(sortTodosWithCompletedLast(arrayMove(todos, oldIndex, newIndex)));
|
||||
},
|
||||
[onPersistTodos, todos]
|
||||
);
|
||||
|
||||
const todoSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
||||
);
|
||||
|
||||
const todoInputValue = newTodoText.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH);
|
||||
const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0);
|
||||
// Filtering is display-only: every handler above still edits the full list,
|
||||
// so reordering or clearing while a filter is active cannot drop hidden items.
|
||||
const visibleTodos = React.useMemo(
|
||||
() => todos.filter((todo) => matchesRankQuery([todo.text], query)),
|
||||
[query, todos],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearCompletedTodos}
|
||||
disabled={disabled || completedTodoCount === 0}
|
||||
className="typography-meta rounded-md px-1.5 py-0.5 text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('rightSidebar.contextNotesTodo.todo.clearCompleted')}
|
||||
</button>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground">{todoInputValue.length}/{PROJECT_TODO_TEXT_MAX_LENGTH}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={todoInputValue}
|
||||
onChange={(event) => setNewTodoText(event.target.value.slice(0, PROJECT_TODO_TEXT_MAX_LENGTH))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleAddTodo();
|
||||
}
|
||||
}}
|
||||
placeholder={t('rightSidebar.contextNotesTodo.todo.inputPlaceholder')}
|
||||
disabled={disabled}
|
||||
className="h-8"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTodo}
|
||||
disabled={disabled || todoInputValue.trim().length === 0}
|
||||
className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.todo.addAria')}
|
||||
title={t('rightSidebar.contextNotesTodo.todo.addAria')}
|
||||
>
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-background/40">
|
||||
{visibleTodos.length === 0 ? (
|
||||
<p className="px-3 py-3 typography-meta text-muted-foreground">
|
||||
{query.trim()
|
||||
? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() })
|
||||
: t('rightSidebar.contextNotesTodo.todo.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={todoSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleTodoReorder}
|
||||
>
|
||||
<SortableContext
|
||||
items={visibleTodos.map((todo) => todo.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<ul className="divide-y divide-border/50">
|
||||
{visibleTodos.map((todo) => {
|
||||
const isExpandedTodo = expandedTodoIds.has(todo.id);
|
||||
return (
|
||||
<SortableTodoItem key={todo.id} id={todo.id}>
|
||||
{(dragHandleProps) => (
|
||||
<div className={cn('flex gap-1.5 px-2.5 py-1.5', isExpandedTodo ? 'items-start' : 'items-center')}>
|
||||
<button
|
||||
type="button"
|
||||
ref={dragHandleProps.setActivatorNodeRef}
|
||||
{...dragHandleProps.attributes}
|
||||
{...dragHandleProps.listeners}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
className="flex h-6 w-4 flex-shrink-0 touch-none items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
|
||||
title={t('rightSidebar.contextNotesTodo.todo.actions.reorder', { text: todo.text })}
|
||||
>
|
||||
<Icon name="draggable" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="flex h-6 items-center">
|
||||
<Checkbox
|
||||
checked={todo.completed}
|
||||
onChange={(checked) => handleToggleTodo(todo.id, checked)}
|
||||
ariaLabel={t('rightSidebar.contextNotesTodo.todo.actions.markComplete', { text: todo.text })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleTodoExpanded(todo.id)}
|
||||
className={cn(
|
||||
'block min-h-6 min-w-0 flex-1 bg-transparent p-0 text-left typography-ui-label leading-normal text-foreground',
|
||||
isExpandedTodo ? 'whitespace-normal break-words' : 'overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
todo.completed && 'text-muted-foreground line-through'
|
||||
)}
|
||||
title={isExpandedTodo ? undefined : todo.text}
|
||||
aria-label={
|
||||
isExpandedTodo
|
||||
? t('rightSidebar.contextNotesTodo.todo.actions.collapse', { text: todo.text })
|
||||
: t('rightSidebar.contextNotesTodo.todo.actions.expand', { text: todo.text })
|
||||
}
|
||||
>
|
||||
{todo.text}
|
||||
</button>
|
||||
<div className="flex h-6 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteTodo(todo.id)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
|
||||
title={t('rightSidebar.contextNotesTodo.todo.actions.delete', { text: todo.text })}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingTodoId === todo.id}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
|
||||
title={t('rightSidebar.contextNotesTodo.todo.actions.send', { text: todo.text })}
|
||||
>
|
||||
<Icon name="send-plane" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={() => onSendToCurrentSession(todo.text)}>
|
||||
{t('rightSidebar.contextNotesTodo.todo.sendMenu.currentSession')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onSendToNewSession(todo.id, todo.text)}>
|
||||
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newSession')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSendToNewWorktreeSession(todo.id, todo.text)}
|
||||
disabled={!canCreateWorktree}
|
||||
>
|
||||
{t('rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SortableTodoItem>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import React from 'react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import type { TodoSendExecution } from '../TodoSendDialog';
|
||||
|
||||
type PendingSendTarget = {
|
||||
kind: 'session' | 'worktree';
|
||||
todoId: string;
|
||||
todoText: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sending a todo to an agent.
|
||||
*
|
||||
* Creating a session, picking its model/agent, and dispatching the prompt is
|
||||
* the heaviest thing this surface does and has nothing to do with how todos are
|
||||
* stored, so it lives apart from the list that triggers it.
|
||||
*/
|
||||
export const useProjectTodoSend = (options: {
|
||||
projectRef: ProjectRef | null;
|
||||
canCreateWorktree: boolean;
|
||||
onActionComplete?: () => void;
|
||||
}) => {
|
||||
const { projectRef, canCreateWorktree, onActionComplete } = options;
|
||||
const { t } = useI18n();
|
||||
|
||||
const [pendingSendTarget, setPendingSendTarget] = React.useState<PendingSendTarget | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
|
||||
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession);
|
||||
const sendMessage = useSessionUIStore((state) => state.sendMessage);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
|
||||
const routeToChat = React.useCallback(() => {
|
||||
setSessionSwitcherOpen(false);
|
||||
}, [setSessionSwitcherOpen]);
|
||||
|
||||
const sendToCurrentSession = React.useCallback(
|
||||
(todoText: string) => {
|
||||
if (!currentSessionId) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.noActiveSession'));
|
||||
return;
|
||||
}
|
||||
routeToChat();
|
||||
const fenced = `\`\`\`md\n${todoText}\n\`\`\``;
|
||||
setPendingInputText(fenced, 'append');
|
||||
toast.success(t('rightSidebar.contextNotesTodo.toast.sentToCurrentSession'));
|
||||
onActionComplete?.();
|
||||
},
|
||||
[currentSessionId, onActionComplete, routeToChat, setPendingInputText, t]
|
||||
);
|
||||
|
||||
const sendToNewSession = React.useCallback(
|
||||
(todoId: string, todoText: string) => {
|
||||
if (!projectRef || sendingTodoId) {
|
||||
return;
|
||||
}
|
||||
setPendingSendTarget({ kind: 'session', todoId, todoText });
|
||||
},
|
||||
[projectRef, sendingTodoId]
|
||||
);
|
||||
|
||||
const sendToNewWorktreeSession = React.useCallback(
|
||||
(todoId: string, todoText: string) => {
|
||||
if (!projectRef || sendingTodoId) {
|
||||
return;
|
||||
}
|
||||
if (!canCreateWorktree) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
|
||||
return;
|
||||
}
|
||||
setPendingSendTarget({ kind: 'worktree', todoId, todoText });
|
||||
},
|
||||
[canCreateWorktree, projectRef, sendingTodoId, t]
|
||||
);
|
||||
|
||||
const confirmSend = React.useCallback(
|
||||
async (execution: TodoSendExecution) => {
|
||||
if (!projectRef || !pendingSendTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visiblePrompt = await renderMagicPrompt('plan.todo.visible', {
|
||||
todo_text: pendingSendTarget.todoText,
|
||||
});
|
||||
const instructionsText = await renderMagicPrompt('plan.todo.instructions', {
|
||||
todo_text: pendingSendTarget.todoText,
|
||||
});
|
||||
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSendingTodoId(pendingSendTarget.todoId);
|
||||
|
||||
try {
|
||||
routeToChat();
|
||||
|
||||
let sessionId: string | null = null;
|
||||
let directoryHint: string | null = projectRef.path;
|
||||
|
||||
if (pendingSendTarget.kind === 'worktree') {
|
||||
if (!canCreateWorktree) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo'));
|
||||
return;
|
||||
}
|
||||
const created = await createWorktreeSessionForNewBranch(projectRef.path, generateBranchName());
|
||||
if (!created?.id) {
|
||||
return;
|
||||
}
|
||||
sessionId = created.id;
|
||||
directoryHint = created.path;
|
||||
} else {
|
||||
const session = await createSession(undefined, projectRef.path, null);
|
||||
if (!session?.id) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.createSessionFailed'));
|
||||
return;
|
||||
}
|
||||
sessionId = session.id;
|
||||
directoryHint = session.directory ?? projectRef.path;
|
||||
initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents ?? []);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionState = useSelectionStore.getState();
|
||||
selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID);
|
||||
if (execution.agent.trim()) {
|
||||
selectionState.saveSessionAgentSelection(sessionId, execution.agent);
|
||||
selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID);
|
||||
selectionState.saveAgentModelVariantForSession(
|
||||
sessionId,
|
||||
execution.agent,
|
||||
execution.providerID,
|
||||
execution.modelID,
|
||||
execution.variant || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
setCurrentSession(sessionId, directoryHint);
|
||||
await sendMessage(
|
||||
visiblePrompt,
|
||||
execution.providerID,
|
||||
execution.modelID,
|
||||
execution.agent.trim() || undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
syntheticParts,
|
||||
execution.variant || undefined,
|
||||
);
|
||||
|
||||
toast.success(
|
||||
pendingSendTarget.kind === 'worktree'
|
||||
? t('rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession')
|
||||
: t('rightSidebar.contextNotesTodo.toast.sentToNewSession')
|
||||
);
|
||||
setPendingSendTarget(null);
|
||||
onActionComplete?.();
|
||||
} catch (error) {
|
||||
const description = error instanceof Error ? error.message : undefined;
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.sendTodoFailed'), description ? { description } : undefined);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setSendingTodoId(null);
|
||||
}
|
||||
},
|
||||
[canCreateWorktree, createSession, initializeNewOpenChamberSession, onActionComplete, pendingSendTarget, projectRef, routeToChat, sendMessage, setCurrentSession, t]
|
||||
);
|
||||
|
||||
const closeDialog = React.useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
setPendingSendTarget(null);
|
||||
}
|
||||
}, [isSubmitting]);
|
||||
|
||||
return {
|
||||
pendingSendTarget,
|
||||
isSubmitting,
|
||||
sendingTodoId,
|
||||
sendToCurrentSession,
|
||||
sendToNewSession,
|
||||
sendToNewWorktreeSession,
|
||||
confirmSend,
|
||||
closeDialog,
|
||||
};
|
||||
};
|
||||
@@ -1,65 +1,58 @@
|
||||
# Session Sidebar Documentation
|
||||
# Session Sidebar
|
||||
|
||||
## Refactor result
|
||||
Sidebar code is organized by the business object it owns. Shared contracts are
|
||||
kept at this root in `types.ts` and `utils.tsx`.
|
||||
|
||||
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
|
||||
- Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level.
|
||||
- **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling.
|
||||
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- **Activity is a dot plus a counter, never a spinner.** The row's left gutter shows a static dot — primary while the session runs (`busy`/`retry`), info while it is unread — and the metadata slot on the right swaps the goal/branch/date group for the elapsed time of the turn (`SessionActivityDuration`, ticking once per second). The readout takes the dot's color in each state — primary while running, info once it is waiting to be read — so the pair reads as one indicator. A running spinner repainted a composited layer per row every frame for the whole turn; the counter conveys the same "something is happening" at 1 fps. The counter follows the unread marker's lifetime exactly: it survives the turn ending, disappears when the session is read, and never lingers on the session being watched (which is marked read as it goes idle). Aggregate indicators for collapsed groups, folders, and projects show the dot only — a group may hold several running turns, so a single counter would have nothing to count. The same treatment applies to the mobile sessions sheet and session switcher rows. The worktree-move indicator stays a spinner: it marks a short user-initiated operation, not a session state.
|
||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
|
||||
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
|
||||
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
|
||||
- When multiple configured projects are checkouts of the same Git repository, exactly one project owns the shared worktree topology: the configured canonical primary root when present, otherwise the first configured source for that repository. Any worktree path that is also a configured project is omitted from subordinate worktree groups, so every directory has one sidebar location while remaining part of bootstrap demand.
|
||||
- `shell/` owns sidebar chrome, navigation, search, confirmations, and switcher effects.
|
||||
- `list/` owns global-first session collection, directory bootstrap demand,
|
||||
layout-owned synchronization, authoritative cleanup, and nearby-session prefetch.
|
||||
- `projects/` owns project zones, grouping, ordering, scroller behavior, project
|
||||
view state, repository state, and worktree presentation.
|
||||
- `sessions/` owns session rows, row actions, expansion, ownership, and activity indicators.
|
||||
- `recent/` owns Recent and managed Chats activity projections.
|
||||
- `folders/` owns folder DnD, bulk actions, archived folders, and folder UI.
|
||||
- Root session right-click and overflow menus expose `Move to worktree`: a submenu
|
||||
listing the canonical primary and linked worktree destinations, with the current
|
||||
target disabled and a separate `New worktree...` action. Opening the submenu
|
||||
refreshes the worktree topology. Moving transfers the full idle subtree. Clean
|
||||
and non-Git sources move session-only; a dirty Git source prompts to move only
|
||||
the session, move all source changes, or cancel. Descendants move first without
|
||||
changes and roll back session-only if a later descendant fails. The root moves
|
||||
last and carries source changes once, which prevents rollback from replaying the
|
||||
transferred patch into the source.
|
||||
- Failure cleanup: a worktree created for the move is removed only after a
|
||||
definite failure. When the change-carrying request fails without confirming
|
||||
its outcome, that worktree is KEPT (it may hold the only copy of the user's
|
||||
changes), both directories are refreshed authoritatively because the session
|
||||
may have moved server-side, and the toast points the user at the destination.
|
||||
Existing destinations are never removed; they get the same guidance.
|
||||
|
||||
## VS Code grouping
|
||||
`MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })`
|
||||
unconditionally. The hook publishes complete directory bootstrap demand,
|
||||
refreshes newly added topology, coalesces control events, and performs
|
||||
authoritative cleanup. Root-level `useGlobalSessionsPolling` remains the only
|
||||
initial and 45-second global poller. `useSessionListSync` must not create a
|
||||
second global polling lifecycle.
|
||||
|
||||
- VS Code uses the **same grouped project tree** as web/desktop (project headers + folders + pinned-first ordering), not a separate flat list. Each open VS Code workspace folder is a project header.
|
||||
- VS Code groups strictly **by open workspace**: `useSessionGrouping` funnels every non-archived session into the project's root group and emits **no per-worktree subgroups** (worktrees aren't registered in VS Code). `getSessionsForProject` buckets sessions to a workspace by exact directory match, so only sessions whose directory is an open workspace folder appear.
|
||||
- VS Code passes `hideDirectoryControls` (clean workspace headers, no worktree/close chrome) and no longer passes `showOnlyMainWorkspace`/`sharedSessionsOnly`. Folders and pinning therefore work natively, scoped to the workspace root.
|
||||
The global sessions cache is the complete source for active and archived
|
||||
coverage. Initialized directory stores only supply sessions missing from that
|
||||
cache. Live busy and retry state comes from `global-session-status`, never from
|
||||
the global cache or persisted history. A failed global or directory fetch keeps
|
||||
existing data; it is never treated as an authoritative empty list.
|
||||
|
||||
## File summaries
|
||||
Web and desktop show managed Chats before optional Recent activity. Chats use
|
||||
their shared managed root for folders and never expose worktree actions. Project
|
||||
display can be all projects or one selected project. The mobile sessions sheet
|
||||
(`apps/MobileSessionsSheet.tsx`) partitions the same way through
|
||||
`partitionSidebarSessions` and lists Chats as a collapsible section above the
|
||||
project tree, with no Recent projection. VS Code excludes worktrees and managed
|
||||
Chats, while retaining its workspace-scoped grouped list and inline archived
|
||||
buckets.
|
||||
|
||||
### Components
|
||||
|
||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all).
|
||||
- `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.
|
||||
- `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.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Rows do not initiate directory bootstrap on mount.
|
||||
- `collapsedActivityIndicator.tsx`: Aggregate busy/unseen dot for collapsed groups and folders.
|
||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
||||
- `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances.
|
||||
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
||||
- `sessionOwnership.ts`: Resolves session directories once into shared project/worktree ownership and folder-scope indexes.
|
||||
|
||||
### Hooks
|
||||
|
||||
- `hooks/useSessionActions.ts`: Centralizes session row actions (select/open, rename, share/unshare, archive/delete, confirmations).
|
||||
- `hooks/useSessionSearchEffects.ts`: Handles search open/close UX and input focus behavior.
|
||||
- `hooks/useSessionPrefetch.ts`: Publishes directory-aware nearby/active session prefetch demand to the shared message loader. Recent may prefetch across projects without substituting the current directory.
|
||||
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
|
||||
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
|
||||
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
|
||||
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior.
|
||||
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
|
||||
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
|
||||
- `hooks/useProjectSessionLists.ts`: Reads live and archived project buckets from the shared ownership index.
|
||||
- `hooks/useAuthoritativeSessionCleanup.ts`: Establishes the first complete active+archived list as a non-destructive baseline, then cleans persisted state only for sessions omitted by a later authoritative snapshot.
|
||||
- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
|
||||
|
||||
### Types and utilities
|
||||
|
||||
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
|
||||
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
|
||||
- `sessionBootstrapDemands.ts`: Builds the deduplicated directory demand plan. Selected directories rank above active projects, expanded groups, visible collapsed groups, and background/collapsed projects.
|
||||
- `utils.tsx`: Shared sidebar utilities (path normalization, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting). Shared session ranking lives in `sync/session-ordering.ts`.
|
||||
Directory demand always includes known project roots and worktrees. Visibility
|
||||
only changes priority. Row mounts must not start bootstrap work. Selection and
|
||||
activity subscriptions stay session-scoped so a structural list update does not
|
||||
make every row observe unrelated streaming updates.
|
||||
|
||||
## Loading rules
|
||||
|
||||
@@ -74,8 +67,10 @@
|
||||
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
|
||||
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
|
||||
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent.
|
||||
- Opening the root-session `Move to worktree` submenu force-refreshes the owning project's worktree topology so externally created worktrees appear without a full reload. While that refresh runs, the menu keeps the last known primary/linked topology visible; if the refresh fails, the stale topology remains and the load failure state stays explicit. Failure cleanup never removes or manages an existing destination worktree.
|
||||
- CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions.
|
||||
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
|
||||
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
|
||||
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
||||
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
|
||||
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type {
|
||||
SessionTreeMoveIntent,
|
||||
SessionTreeMoveMessages,
|
||||
} from '@/lib/worktrees/sessionWorktreeMove';
|
||||
|
||||
type MockDialogProps = React.PropsWithChildren<{
|
||||
open?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
mock.module('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children, open = true }: MockDialogProps) => (open ? <>{children}</> : null),
|
||||
DialogContent: ({ children, id, className }: MockDialogProps) => (
|
||||
<div id={id} className={className}>{children}</div>
|
||||
),
|
||||
DialogDescription: ({ children }: MockDialogProps) => <p>{children}</p>,
|
||||
DialogFooter: ({ children, className }: MockDialogProps) => <div className={className}>{children}</div>,
|
||||
DialogHeader: ({ children }: MockDialogProps) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: MockDialogProps) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
const { SessionWorktreeMoveConfirmDialog } = await import('./SessionWorktreeMoveConfirmDialog');
|
||||
|
||||
const makeMoveMessages = (): SessionTreeMoveMessages => ({
|
||||
success: 'move succeeded',
|
||||
failure: 'move failed',
|
||||
sourceVerificationFailed: 'source verification failed',
|
||||
applyChangesFailed: 'apply changes failed',
|
||||
changesMayBeInDestination: 'changes may be in destination',
|
||||
});
|
||||
|
||||
const makeExistingIntent = (): SessionTreeMoveIntent => ({
|
||||
kind: 'existing',
|
||||
root: {
|
||||
id: 'root',
|
||||
slug: 'root',
|
||||
projectID: 'project-1',
|
||||
directory: '/source',
|
||||
title: 'Root session',
|
||||
version: '1',
|
||||
time: { created: 0, updated: 0 },
|
||||
} satisfies Session,
|
||||
descendants: [],
|
||||
sourceDirectory: '/source',
|
||||
destination: {
|
||||
path: '/destination',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'feature',
|
||||
label: 'Destination',
|
||||
worktreeStatus: 'ready',
|
||||
worktreeSource: 'existing',
|
||||
},
|
||||
messages: makeMoveMessages(),
|
||||
});
|
||||
|
||||
describe('SessionWorktreeMoveConfirmDialog', () => {
|
||||
test('renders stable semantic hooks, dirty file count, and the staged warning', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<SessionWorktreeMoveConfirmDialog
|
||||
value={{
|
||||
intent: makeExistingIntent(),
|
||||
dirtyFileCount: 2,
|
||||
stagedFileCount: 1,
|
||||
}}
|
||||
onMoveSessionOnly={() => {}}
|
||||
onMoveAllChanges={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('id="session-worktree-move-confirm-dialog"');
|
||||
expect(markup).toContain('data-session-worktree-move-action="session-only"');
|
||||
expect(markup).toContain('data-session-worktree-move-action="all-changes"');
|
||||
expect(markup).toContain('data-session-worktree-move-action="cancel"');
|
||||
expect(markup).toContain('autofocus=""');
|
||||
expect(markup).toContain('2');
|
||||
expect(markup).toContain('data-session-worktree-move-staged-warning="true"');
|
||||
});
|
||||
|
||||
test('omits the staged warning when no staged files are present', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<SessionWorktreeMoveConfirmDialog
|
||||
value={{
|
||||
intent: makeExistingIntent(),
|
||||
dirtyFileCount: 3,
|
||||
stagedFileCount: 0,
|
||||
}}
|
||||
onMoveSessionOnly={() => {}}
|
||||
onMoveAllChanges={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).not.toContain('data-session-worktree-move-staged-warning="true"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { SessionTreeMoveConfirmation } from '@/lib/worktrees/sessionWorktreeMove';
|
||||
|
||||
export type SessionWorktreeMoveConfirmDialogProps = {
|
||||
value: SessionTreeMoveConfirmation | null;
|
||||
onMoveSessionOnly: () => void;
|
||||
onMoveAllChanges: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function SessionWorktreeMoveConfirmDialog(props: SessionWorktreeMoveConfirmDialogProps): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const { value, onMoveSessionOnly, onMoveAllChanges, onCancel } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={Boolean(value)} onOpenChange={(open) => { if (!open) onCancel(); }}>
|
||||
<DialogContent
|
||||
id="session-worktree-move-confirm-dialog"
|
||||
showCloseButton={false}
|
||||
className="max-w-md gap-5"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('sessions.sidebar.session.moveToWorktree.confirm.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('sessions.sidebar.session.moveToWorktree.confirm.changedFiles', {
|
||||
count: value?.dirtyFileCount ?? 0,
|
||||
})}{' '}
|
||||
{t('sessions.sidebar.session.moveToWorktree.confirm.ownership')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 typography-ui-label text-muted-foreground">
|
||||
<p>{t('sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp')}</p>
|
||||
<p>{t('sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp')}</p>
|
||||
{value && value.stagedFileCount > 0 ? (
|
||||
<p data-session-worktree-move-staged-warning="true">
|
||||
{t('sessions.sidebar.session.moveToWorktree.confirm.stagedWarning')}
|
||||
</p>
|
||||
) : null}
|
||||
<p>{t('sessions.sidebar.session.moveToWorktree.confirm.baseWarning')}</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
data-session-worktree-move-action="cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('sessions.sidebar.session.moveToWorktree.confirm.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-session-worktree-move-action="all-changes"
|
||||
onClick={onMoveAllChanges}
|
||||
>
|
||||
{t('sessions.sidebar.session.moveToWorktree.confirm.allChanges')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
autoFocus
|
||||
data-session-worktree-move-action="session-only"
|
||||
onClick={onMoveSessionOnly}
|
||||
>
|
||||
{t('sessions.sidebar.session.moveToWorktree.confirm.sessionOnly')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
const isSubtaskSession = (session: Session): boolean => {
|
||||
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
};
|
||||
|
||||
const isArchivedSession = (session: Session): boolean => {
|
||||
return Boolean(session.time?.archived);
|
||||
};
|
||||
|
||||
const getSessionUpdatedAt = (session: Session): number => {
|
||||
const updated = session.time?.updated;
|
||||
const created = session.time?.created;
|
||||
if (typeof updated === 'number' && Number.isFinite(updated)) {
|
||||
return updated;
|
||||
}
|
||||
if (typeof created === 'number' && Number.isFinite(created)) {
|
||||
return created;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Recent contains non-archived root sessions that are active now or were
|
||||
// updated within the retention window. The caller applies shared lifecycle
|
||||
// ordering after this membership filter; batching ("Show more") handles long
|
||||
// windows in the UI.
|
||||
export const deriveRecentSessions = (
|
||||
sessions: Session[],
|
||||
activeSessionIds: ReadonlySet<string>,
|
||||
now = Date.now(),
|
||||
): Session[] => {
|
||||
const minUpdatedAt = now - RECENT_SESSION_MAX_AGE_MS;
|
||||
return sessions.filter((session) => {
|
||||
if (isArchivedSession(session) || isSubtaskSession(session)) {
|
||||
return false;
|
||||
}
|
||||
return activeSessionIds.has(session.id) || getSessionUpdatedAt(session) >= minUpdatedAt;
|
||||
});
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionNode } from './types';
|
||||
|
||||
export type CollapsedActivityState = 'active' | 'unread' | null;
|
||||
|
||||
export const mergeCollapsedActivityStates = (
|
||||
current: CollapsedActivityState,
|
||||
next: CollapsedActivityState,
|
||||
): CollapsedActivityState => {
|
||||
if (current === 'active' || next === 'active') return 'active';
|
||||
if (current === 'unread' || next === 'unread') return 'unread';
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSessionNodeActivityState = (
|
||||
node: SessionNode,
|
||||
activeSessionIds: Set<string>,
|
||||
unreadSessionIds: Set<string>,
|
||||
includeUnreadSubtasks: boolean,
|
||||
): CollapsedActivityState => {
|
||||
if (activeSessionIds.has(node.session.id)) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
let state: CollapsedActivityState = null;
|
||||
const isSubtask = Boolean((node.session as Session & { parentID?: string | null }).parentID);
|
||||
if (unreadSessionIds.has(node.session.id) && (includeUnreadSubtasks || !isSubtask)) {
|
||||
state = 'unread';
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
state = mergeCollapsedActivityStates(
|
||||
state,
|
||||
getSessionNodeActivityState(child, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||
);
|
||||
if (state === 'active') return state;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const getSessionNodesActivityState = (
|
||||
nodes: SessionNode[],
|
||||
activeSessionIds: Set<string>,
|
||||
unreadSessionIds: Set<string>,
|
||||
includeUnreadSubtasks: boolean,
|
||||
): CollapsedActivityState => {
|
||||
let state: CollapsedActivityState = null;
|
||||
for (const node of nodes) {
|
||||
state = mergeCollapsedActivityStates(
|
||||
state,
|
||||
getSessionNodeActivityState(node, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||
);
|
||||
if (state === 'active') return state;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type BulkActionCapture = {
|
||||
onCreateFolderAndMove: () => void;
|
||||
};
|
||||
|
||||
let bulkActionCapture: BulkActionCapture | null = null;
|
||||
|
||||
mock.module('./BulkActionBar', () => ({
|
||||
BulkActionBar: (props: BulkActionCapture) => {
|
||||
bulkActionCapture = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('./ConfirmDialogs', () => ({
|
||||
BulkSessionDeleteConfirmDialog: () => null,
|
||||
}));
|
||||
|
||||
const { SessionBulkActions } = await import('./SessionBulkActions');
|
||||
|
||||
describe('SessionBulkActions public behavior', () => {
|
||||
test('moves the selected sessions into a newly created folder while a row edit is active', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const originalFolders = useSessionFoldersStore.getState();
|
||||
const originalSelection = useSessionMultiSelectStore.getState();
|
||||
const cssDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CSS');
|
||||
const renameRequests: Array<{ scopeKey: string; folder: { id: string; name: string } }> = [];
|
||||
const moved: Array<{ scopeKey: string; folderId: string; ids: string[] }> = [];
|
||||
useSessionFoldersStore.setState({
|
||||
foldersMap: {},
|
||||
addSessionsToFolder: (scopeKey, folderId, ids) => moved.push({ scopeKey, folderId, ids }),
|
||||
});
|
||||
useSessionMultiSelectStore.setState({
|
||||
enabled: true,
|
||||
selectedIds: new Set(['session-a']),
|
||||
scopeKey: 'project-a',
|
||||
anchorId: 'session-a',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'CSS', {
|
||||
configurable: true,
|
||||
value: { escape: (value: string) => value },
|
||||
});
|
||||
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<I18nProvider>
|
||||
<SessionBulkActions
|
||||
getFolderScopesForProject={() => [{ scopeKey: '/workspace', directory: '/workspace' }]}
|
||||
isInlineEditing
|
||||
startFolderRename={(scopeKey, folder) => renameRequests.push({ scopeKey, folder })}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
));
|
||||
expect(bulkActionCapture).not.toBeNull();
|
||||
|
||||
await act(async () => bulkActionCapture?.onCreateFolderAndMove());
|
||||
const createdFolder = useSessionFoldersStore.getState().foldersMap['/workspace']?.[0];
|
||||
expect(createdFolder?.name).toBe('New folder');
|
||||
expect(renameRequests).toEqual([{ scopeKey: '/workspace', folder: createdFolder }]);
|
||||
expect(moved).toEqual([{ scopeKey: '/workspace', folderId: createdFolder?.id ?? '', ids: ['session-a'] }]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
useSessionFoldersStore.setState(originalFolders, true);
|
||||
useSessionMultiSelectStore.setState(originalSelection, true);
|
||||
if (cssDescriptor) Object.defineProperty(globalThis, 'CSS', cssDescriptor);
|
||||
else Reflect.deleteProperty(globalThis, 'CSS');
|
||||
bulkActionCapture = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { BulkActionBar } from './BulkActionBar';
|
||||
import { BulkSessionDeleteConfirmDialog, type BulkDeleteSessionsConfirmState } from '../shell/ConfirmDialogs';
|
||||
import { useSidebarBulkActions } from './useSidebarBulkActions';
|
||||
|
||||
type Props = {
|
||||
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
|
||||
isInlineEditing: boolean;
|
||||
startFolderRename: (scopeKey: string, folder: { id: string; name: string }) => void;
|
||||
};
|
||||
|
||||
/** Owns the sidebar selection projection and its destructive confirmation. */
|
||||
export function SessionBulkActions({ getFolderScopesForProject, isInlineEditing, startFolderRename }: Props): React.ReactNode {
|
||||
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||
const addSessionsToFolder = useSessionFoldersStore((state) => state.addSessionsToFolder);
|
||||
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
|
||||
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
|
||||
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||
const bulk = useSidebarBulkActions({
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
getFolderScopesForProject,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename: (scopeKey) => {
|
||||
const folder = createFolder(scopeKey, 'New folder');
|
||||
startFolderRename(scopeKey, folder);
|
||||
return folder;
|
||||
},
|
||||
archiveSessions,
|
||||
unarchiveSessions,
|
||||
deleteSessions,
|
||||
setBulkDeleteConfirm,
|
||||
});
|
||||
|
||||
return <>
|
||||
{bulk.selectionModeEnabled && bulk.hasSelection ? <BulkActionBar
|
||||
selectedCount={bulk.selectedIdsSize}
|
||||
scopeKey={bulk.derivedSelectionScope}
|
||||
scopeFolders={bulk.bulkScopeFolders}
|
||||
archivedBucket={bulk.bulkScopeIsArchived}
|
||||
onMoveToFolder={bulk.handleBulkMoveToFolder}
|
||||
onCreateFolderAndMove={bulk.handleBulkCreateFolderAndMove}
|
||||
onRemoveFromFolder={bulk.handleBulkRemoveFromFolder}
|
||||
canRemoveFromFolder={bulk.bulkCanRemoveFromFolder}
|
||||
onRestore={bulk.handleBulkRestore}
|
||||
onDelete={bulk.handleBulkDelete}
|
||||
onDone={bulk.handleExitSelectionMode}
|
||||
/> : null}
|
||||
<BulkSessionDeleteConfirmDialog
|
||||
value={bulkDeleteConfirm}
|
||||
setValue={setBulkDeleteConfirm}
|
||||
showDeletionDialog={showDeletionDialog}
|
||||
setShowDeletionDialog={setShowDeletionDialog}
|
||||
onConfirm={bulk.confirmBulkDelete}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type DragEnd = (event: {
|
||||
active: { data: { current: { type: string; sessionId: string } } };
|
||||
over: { data: { current: { type: string; folderId: string } } } | null;
|
||||
}) => void;
|
||||
|
||||
let handleDragEnd: DragEnd | null = null;
|
||||
|
||||
mock.module('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children, onDragEnd }: { children: React.ReactNode; onDragEnd: DragEnd }) => {
|
||||
handleDragEnd = onDragEnd;
|
||||
return <>{children}</>;
|
||||
},
|
||||
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PointerSensor: class {},
|
||||
closestCenter: () => null,
|
||||
useSensor: () => null,
|
||||
useSensors: () => [],
|
||||
useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: () => undefined, isDragging: false }),
|
||||
useDroppable: () => ({ setNodeRef: () => undefined, isOver: false }),
|
||||
}));
|
||||
|
||||
const { SessionFolderDndScope } = await import('./sessionFolderDnd');
|
||||
|
||||
describe('SessionFolderDndScope public behavior', () => {
|
||||
test('routes a session-folder drop without depending on row edit or menu state', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const drops: Array<{ sessionId: string; folderId: string }> = [];
|
||||
|
||||
try {
|
||||
await act(async () => root.render(
|
||||
<SessionFolderDndScope
|
||||
scopeKey="/workspace"
|
||||
hasFolders
|
||||
onSessionDroppedOnFolder={(sessionId, folderId) => drops.push({ sessionId, folderId })}
|
||||
>
|
||||
{null}
|
||||
</SessionFolderDndScope>,
|
||||
));
|
||||
expect(handleDragEnd).not.toBeNull();
|
||||
|
||||
await act(async () => handleDragEnd?.({
|
||||
active: { data: { current: { type: 'session', sessionId: 'session-a' } } },
|
||||
over: { data: { current: { type: 'folder', folderId: 'folder-a' } } },
|
||||
}));
|
||||
expect(drops).toEqual([{ sessionId: 'session-a', folderId: 'folder-a' }]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
handleDragEnd = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
getArchivedScopeKey,
|
||||
resolveArchivedFolderName,
|
||||
} from '../utils';
|
||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
||||
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||
|
||||
type ProjectForArchivedFolders = {
|
||||
id: string;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveSelectionFolderScopes } from './useSidebarBulkActions';
|
||||
|
||||
describe('sidebar bulk project scopes', () => {
|
||||
test('uses every root and worktree scope owned by the selected project', () => {
|
||||
const scopes = resolveSelectionFolderScopes('project-a', (projectId) => projectId === 'project-a'
|
||||
? [
|
||||
{ scopeKey: '/workspace/project-a', directory: '/workspace/project-a' },
|
||||
{ scopeKey: '/workspace/project-a-worktree', directory: '/workspace/project-a-worktree' },
|
||||
]
|
||||
: []);
|
||||
|
||||
expect(scopes).toEqual(['/workspace/project-a', '/workspace/project-a-worktree']);
|
||||
});
|
||||
|
||||
test('keeps a directory scope when no project scope owns it', () => {
|
||||
expect(resolveSelectionFolderScopes('/workspace/vscode', () => [])).toEqual(['/workspace/vscode']);
|
||||
});
|
||||
});
|
||||
+15
-10
@@ -13,7 +13,7 @@ type Args = {
|
||||
* map resolves it to the project's folder scopes (root + worktrees). When
|
||||
* the scope is missing here it is treated as a plain directory scope.
|
||||
*/
|
||||
folderScopesByProject: Map<string, Array<{ scopeKey: string; directory: string | null }>>;
|
||||
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
|
||||
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
@@ -26,6 +26,17 @@ type Args = {
|
||||
} | null>>;
|
||||
};
|
||||
|
||||
export const resolveSelectionFolderScopes = (
|
||||
selectionScope: string | null,
|
||||
getFolderScopesForProject: Args['getFolderScopesForProject'],
|
||||
): string[] => {
|
||||
if (!selectionScope) return [];
|
||||
const projectScopes = getFolderScopesForProject(selectionScope);
|
||||
return projectScopes.length > 0
|
||||
? projectScopes.map((scope) => scope.scopeKey)
|
||||
: [selectionScope];
|
||||
};
|
||||
|
||||
/**
|
||||
* Bulk-action logic for the sidebar. The hot-path concern is that this
|
||||
* hook subscribes to `useSessionMultiSelectStore` — which can fire on
|
||||
@@ -46,7 +57,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
folderScopesByProject,
|
||||
getFolderScopesForProject,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
@@ -101,14 +112,8 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
// The selection scope is a project id; folders live per directory scope
|
||||
// (project root + each worktree). Resolve all of them, in project order.
|
||||
const selectionFolderScopes = React.useMemo<string[]>(() => {
|
||||
if (!derivedSelectionScope) return [];
|
||||
const projectScopes = folderScopesByProject.get(derivedSelectionScope);
|
||||
if (projectScopes && projectScopes.length > 0) {
|
||||
return projectScopes.map((scope) => scope.scopeKey);
|
||||
}
|
||||
// Fallback: the scope is already a directory (e.g. VS Code workspaces).
|
||||
return [derivedSelectionScope];
|
||||
}, [derivedSelectionScope, folderScopesByProject]);
|
||||
return resolveSelectionFolderScopes(derivedSelectionScope, getFolderScopesForProject);
|
||||
}, [derivedSelectionScope, getFolderScopesForProject]);
|
||||
|
||||
const bulkScopeFolders = React.useMemo(() => {
|
||||
return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []);
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from '../authoritativeSessionCleanup';
|
||||
|
||||
const session = (id: string, directory = '/repo'): Session => ({ id, directory }) as Session;
|
||||
|
||||
describe('authoritative session cleanup', () => {
|
||||
test('does not infer deletion from the first authoritative startup snapshot', () => {
|
||||
const current = buildAuthoritativeSessionIdentityMap([]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(null, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('finds sessions omitted after an established authoritative baseline', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([
|
||||
session('deleted'),
|
||||
session('retained'),
|
||||
]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('retained')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([
|
||||
{ directory: '/repo', sessionId: 'deleted' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('treats archive membership as retained authority', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('archived')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([
|
||||
{ ...session('archived'), time: { archived: 10 } } as Session,
|
||||
]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat a directory move as session deletion', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-a')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-b')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: simulate the projectSessionMeta computation from the hook
|
||||
// (same visitNodes logic as useProjectSessionSelection.ts lines 46-71)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ProjectSection = {
|
||||
project: { id: string; normalizedPath: string };
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
function computeProjectMeta(projectSections: ProjectSection[]) {
|
||||
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
|
||||
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
|
||||
|
||||
const visitNodes = (
|
||||
projectId: string,
|
||||
projectRoot: string,
|
||||
fallbackDirectory: string | null,
|
||||
nodes: SessionNode[],
|
||||
) => {
|
||||
if (!metaByProject.has(projectId)) {
|
||||
metaByProject.set(projectId, new Map());
|
||||
}
|
||||
const projectMap = metaByProject.get(projectId)!;
|
||||
nodes.forEach((node) => {
|
||||
const sessionDirectory = (
|
||||
node.worktree?.path
|
||||
?? (node.session as Session & { directory?: string | null }).directory
|
||||
?? fallbackDirectory
|
||||
?? projectRoot
|
||||
).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
|
||||
projectMap.set(node.session.id, { directory: sessionDirectory });
|
||||
if (!firstSessionByProject.has(projectId)) {
|
||||
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
projectSections.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
|
||||
});
|
||||
});
|
||||
|
||||
return { metaByProject, firstSessionByProject };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeSession = (id: string, directory?: string): Session =>
|
||||
({ id, directory } as unknown as Session);
|
||||
|
||||
const rootSession1 = makeSession('root-session-1', '/workspace/project');
|
||||
const rootSession2 = makeSession('root-session-2', '/workspace/project');
|
||||
const worktreeSession1 = makeSession('wt-session-1', '/workspace/project-wt');
|
||||
|
||||
const project2Session1 = makeSession('project-2-session-1', '/workspace/project-2');
|
||||
const project2Session2 = makeSession('project-2-session-2', '/workspace/project-2');
|
||||
|
||||
const WORKTREE_PATH = '/workspace/project-wt';
|
||||
|
||||
// staleSections: root group only, no worktree group
|
||||
const staleSections: ProjectSection[] = [
|
||||
{
|
||||
project: { id: 'project-1', normalizedPath: '/workspace/project' },
|
||||
groups: [
|
||||
{
|
||||
id: 'root',
|
||||
label: 'Main',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: '/workspace/project',
|
||||
sessions: [
|
||||
{ session: rootSession1, children: [], worktree: null },
|
||||
{ session: rootSession2, children: [], worktree: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// updatedSections: includes the worktree group
|
||||
const updatedSections: ProjectSection[] = [
|
||||
{
|
||||
project: { id: 'project-1', normalizedPath: '/workspace/project' },
|
||||
groups: [
|
||||
{
|
||||
id: 'root',
|
||||
label: 'Main',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: '/workspace/project',
|
||||
sessions: [
|
||||
{ session: rootSession1, children: [], worktree: null },
|
||||
{ session: rootSession2, children: [], worktree: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wt-group',
|
||||
label: 'feature-branch',
|
||||
branch: 'feature-branch',
|
||||
description: 'Worktree at ' + WORKTREE_PATH,
|
||||
isMain: false,
|
||||
worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' },
|
||||
directory: WORKTREE_PATH,
|
||||
sessions: [
|
||||
{ session: worktreeSession1, children: [], worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// project-2Sections: separate project for project-switching tests
|
||||
const project2Sections: ProjectSection[] = [
|
||||
{
|
||||
project: { id: 'project-2', normalizedPath: '/workspace/project-2' },
|
||||
groups: [
|
||||
{
|
||||
id: 'root',
|
||||
label: 'Main',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: '/workspace/project-2',
|
||||
sessions: [
|
||||
{ session: project2Session1, children: [], worktree: null },
|
||||
{ session: project2Session2, children: [], worktree: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useProjectSessionSelection — worktree session click race', () => {
|
||||
test('stale projectSections (no worktree group) excludes worktree sessions from projectMap', () => {
|
||||
const { metaByProject } = computeProjectMeta(staleSections);
|
||||
const projectMap = metaByProject.get('project-1');
|
||||
|
||||
// Root sessions are present
|
||||
expect(projectMap?.has('root-session-1')).toBe(true);
|
||||
expect(projectMap?.has('root-session-2')).toBe(true);
|
||||
|
||||
// Worktree session is NOT present — this is what triggers the bug
|
||||
expect(projectMap?.has('wt-session-1')).toBe(false);
|
||||
});
|
||||
|
||||
test('stale data firstSessionByProject points to first root session, not worktree session', () => {
|
||||
const { firstSessionByProject } = computeProjectMeta(staleSections);
|
||||
|
||||
// Path C would fall back to firstSessionByProject, which is the first ROOT session
|
||||
const first = firstSessionByProject.get('project-1');
|
||||
expect(first?.id).toBe('root-session-1');
|
||||
expect(first?.id).not.toBe('wt-session-1');
|
||||
});
|
||||
|
||||
test('updated projectSections includes all sessions including worktree', () => {
|
||||
const { metaByProject } = computeProjectMeta(updatedSections);
|
||||
const projectMap = metaByProject.get('project-1');
|
||||
|
||||
expect(projectMap?.has('root-session-1')).toBe(true);
|
||||
expect(projectMap?.has('root-session-2')).toBe(true);
|
||||
expect(projectMap?.has('wt-session-1')).toBe(true);
|
||||
});
|
||||
|
||||
test('guard preserves currentSessionId when projectMap is stale (the bug fix)', () => {
|
||||
const { metaByProject, firstSessionByProject } = computeProjectMeta(staleSections);
|
||||
const projectMap = metaByProject.get('project-1')!;
|
||||
const currentSessionId = 'wt-session-1';
|
||||
|
||||
// Path A fails: currentSessionId is set but not in stale projectMap
|
||||
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
|
||||
expect(pathAHit).toBe(false);
|
||||
|
||||
// Guard: if (currentSessionId) return;
|
||||
// This is what prevents the fallthrough to Path C (auto-select wrong session)
|
||||
// Without the guard, Path C would select firstSessionByProject = root-session-1
|
||||
// instead of preserving the user's wt-session-1 selection
|
||||
const fallback = firstSessionByProject.get('project-1')?.id ?? null;
|
||||
expect(fallback).toBe('root-session-1');
|
||||
expect(fallback).not.toBe(currentSessionId);
|
||||
});
|
||||
|
||||
test('second click works correctly when projectSections is updated', () => {
|
||||
const { metaByProject } = computeProjectMeta(updatedSections);
|
||||
const projectMap = metaByProject.get('project-1')!;
|
||||
const currentSessionId = 'wt-session-1';
|
||||
|
||||
// After data arrives, Path A succeeds — no guard needed
|
||||
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
|
||||
expect(pathAHit).toBe(true);
|
||||
});
|
||||
|
||||
test('project switch: guard does NOT fire when currentSessionId matches new project', () => {
|
||||
// Simulates: user clicks a session in project-2 (normal click, not worktree)
|
||||
const { metaByProject } = computeProjectMeta(project2Sections);
|
||||
const projectMap = metaByProject.get('project-2')!;
|
||||
const currentSessionId = 'project-2-session-1';
|
||||
|
||||
// Path A succeeds — the session is in the new project's projectMap
|
||||
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
|
||||
expect(pathAHit).toBe(true);
|
||||
|
||||
// Guard condition only fires when Path A fails — should not fire here
|
||||
const guardWouldFire = Boolean(currentSessionId && !(projectMap?.has(currentSessionId)));
|
||||
expect(guardWouldFire).toBe(false);
|
||||
});
|
||||
|
||||
test('guard does NOT fire when currentSessionId is null (deleted/archived session)', () => {
|
||||
const { metaByProject } = computeProjectMeta(staleSections);
|
||||
const projectMap = metaByProject.get('project-1')!;
|
||||
const currentSessionId = null;
|
||||
|
||||
// Path A: currentSessionId is null → skipped
|
||||
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
|
||||
expect(pathAHit).toBe(false);
|
||||
|
||||
// Guard: currentSessionId is null → skipped, falls through to Path B/C
|
||||
const guardWouldFire = currentSessionId !== null && !pathAHit;
|
||||
expect(guardWouldFire).toBe(false);
|
||||
});
|
||||
|
||||
test('guard does NOT fire for empty projects — falls through to Path B (open draft)', () => {
|
||||
// Empty project: no groups/sessions in projectSections
|
||||
const emptySections: ProjectSection[] = [
|
||||
{
|
||||
project: { id: 'empty-project', normalizedPath: '/workspace/empty' },
|
||||
groups: [],
|
||||
},
|
||||
];
|
||||
const { metaByProject } = computeProjectMeta(emptySections);
|
||||
const projectMap = metaByProject.get('empty-project');
|
||||
const currentSessionId = 'some-session-id';
|
||||
|
||||
// projectMap is undefined for empty project
|
||||
expect(projectMap).toBe(undefined);
|
||||
|
||||
// Guard: projectMap is undefined → skipped, falls through to Path B
|
||||
// which opens a new session draft for the empty project
|
||||
const guardWouldFire = Boolean(currentSessionId && projectMap);
|
||||
expect(guardWouldFire).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import React from 'react';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
|
||||
type SafeStorageLike = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
};
|
||||
|
||||
type Keys = {
|
||||
sessionExpanded: string;
|
||||
projectCollapse: string;
|
||||
groupOrder: string;
|
||||
groupCollapse: string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
isVSCode: boolean;
|
||||
safeStorage: SafeStorageLike;
|
||||
keys: Keys;
|
||||
groupOrderByProject: Map<string, string[]>;
|
||||
collapsedGroups: Set<string>;
|
||||
setExpandedParents: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
setCollapsedProjects: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
};
|
||||
|
||||
export const useSidebarPersistence = (args: Args) => {
|
||||
const {
|
||||
isVSCode,
|
||||
safeStorage,
|
||||
keys,
|
||||
groupOrderByProject,
|
||||
collapsedGroups,
|
||||
setExpandedParents,
|
||||
setCollapsedProjects,
|
||||
} = args;
|
||||
|
||||
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
||||
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
|
||||
|
||||
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
||||
if (isVSCode) {
|
||||
return;
|
||||
}
|
||||
const collapsed = pendingCollapsedProjects.current;
|
||||
pendingCollapsedProjects.current = null;
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
if (!collapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { projects } = useProjectsStore.getState();
|
||||
const updatedProjects = projects.map((project) => ({
|
||||
...project,
|
||||
sidebarCollapsed: collapsed.has(project.id),
|
||||
}));
|
||||
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
|
||||
}, [isVSCode]);
|
||||
|
||||
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
|
||||
if (typeof window === 'undefined' || isVSCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingCollapsedProjects.current = collapsed;
|
||||
if (persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
|
||||
flushCollapsedProjectsPersist();
|
||||
}, 700);
|
||||
}, [isVSCode, flushCollapsedProjectsPersist]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== 'undefined' && persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
pendingCollapsedProjects.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const storedParents = safeStorage.getItem(keys.sessionExpanded);
|
||||
if (storedParents) {
|
||||
const parsed = JSON.parse(storedParents);
|
||||
if (Array.isArray(parsed)) {
|
||||
setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string')));
|
||||
}
|
||||
}
|
||||
const storedProjects = safeStorage.getItem(keys.projectCollapse);
|
||||
if (storedProjects) {
|
||||
const parsed = JSON.parse(storedProjects);
|
||||
if (Array.isArray(parsed)) {
|
||||
setCollapsedProjects(new Set(parsed.filter((item) => typeof item === 'string')));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const serialized = Object.fromEntries(groupOrderByProject.entries());
|
||||
safeStorage.setItem(keys.groupOrder, JSON.stringify(serialized));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [groupOrderByProject, keys.groupOrder, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [collapsedGroups, keys.groupCollapse, safeStorage]);
|
||||
|
||||
return { scheduleCollapsedProjectsPersist };
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildSessionBootstrapDemands } from './sessionBootstrapDemands';
|
||||
|
||||
describe('SessionProjectCollection', () => {
|
||||
test('preserves authoritative background demand when its visible rows are absent', () => {
|
||||
const demands = buildSessionBootstrapDemands({
|
||||
knownDirectories: ['/project', '/project/worktree'],
|
||||
activeProjectDirectory: '/project',
|
||||
activeProjectId: 'project',
|
||||
collapsedProjects: new Set(),
|
||||
collapsedGroups: new Set(),
|
||||
currentDirectory: null,
|
||||
currentSessionDirectory: null,
|
||||
});
|
||||
|
||||
expect(demands.map((demand) => demand.directory)).toEqual(['/project', '/project/worktree']);
|
||||
expect(demands[0]?.priority).toBe('active-project');
|
||||
expect(demands[1]?.priority).toBe('background');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,612 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { usePrefetchSessionMessages } from '@/sync/use-sync';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
import { useArchivedAutoFolders } from '../folders/useArchivedAutoFolders';
|
||||
import { ProjectSessionSelectionEffect } from '../projects/useProjectSessionSelection';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { useRecentSessionCollection, useSessionProjectCollection } from './sessionCollection';
|
||||
import { buildSessionBootstrapDemands } from './sessionBootstrapDemands';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
import { createSessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||
import { useProjectSessionLists } from '../projects/useProjectSessionLists';
|
||||
import { useSessionSidebarSections } from '../projects/useSessionSidebarSections';
|
||||
import { SessionPrefetchEffect } from './useSessionPrefetch';
|
||||
import { normalizePath } from '../utils';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { SessionProjectScroller } from '../projects/SessionProjectScroller';
|
||||
import { useSessionGrouping } from '../projects/useSessionGrouping';
|
||||
import { useStickyProjectHeaders } from '../projects/useStickyProjectHeaders';
|
||||
import { SessionBulkActions } from '../folders/SessionBulkActions';
|
||||
import { RecentSessionSection } from '../recent/RecentSessionSection';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import type { useSessionProjectViewState } from '../projects/useSessionProjectViewState';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import type { DeleteSessionConfirmState } from '../sessions/useSessionActions';
|
||||
import { useExpandedParents } from '../sessions/useExpandedParents';
|
||||
import { SessionGroupSection } from '../projects/SessionGroupSection';
|
||||
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory } from '@/lib/chatDirectories';
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
|
||||
const PR_NO_PR_RETRY_MS = 5 * 60_000;
|
||||
|
||||
const isRootSession = (session: Session): boolean => {
|
||||
// SAFETY: OpenCode attaches parentID to hierarchical session records,
|
||||
// although the SDK's base Session type does not currently declare it.
|
||||
return !(session as Session & { parentID?: string | null }).parentID;
|
||||
};
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
|
||||
type SessionProjectCollectionProps = {
|
||||
topology: {
|
||||
projects: Project[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
knownDirectories: Set<string>;
|
||||
isVSCode: boolean;
|
||||
worktreeMetadata: Map<string, WorktreeMetadata>;
|
||||
gitBranches: Map<string, string | null>;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
projectRootBranches: Map<string, string | null>;
|
||||
lastRepoStatus: boolean;
|
||||
};
|
||||
view: {
|
||||
isVisible: boolean;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
activeProjectId: string | null;
|
||||
showInlineArchived: boolean;
|
||||
useGroupedSections: boolean;
|
||||
homeDirectory: string | null;
|
||||
mobileVariant: boolean;
|
||||
hideDirectoryControls: boolean;
|
||||
showOnlyMainWorkspace: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stickyZoneHeaders: boolean;
|
||||
projectSortOrder: import('@/stores/useSessionDisplayStore').ProjectSortOrder;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
isSessionsLoading: boolean;
|
||||
isWorktreeTopologyLoading: boolean;
|
||||
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
|
||||
projectView: ReturnType<typeof useSessionProjectViewState>['state'];
|
||||
};
|
||||
actions: {
|
||||
rowActions: {
|
||||
allowReselect: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
isSessionSearchOpen: boolean;
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
};
|
||||
alwaysShowActions: boolean;
|
||||
notifyOnSubtasks: boolean;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openWorktreesPage: (id: string) => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
startSessionWorktreeMenuLoad: SessionTreeItemProps['startSessionWorktreeMenuLoad'];
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
initialActiveSessionByProject: Map<string, string>;
|
||||
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||
projectViewActions: Pick<
|
||||
ReturnType<typeof useSessionProjectViewState>['actions'],
|
||||
'getOrderedGroups' | 'setGroupOrderByProject' | 'toggleGroup' | 'toggleProject'
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topology, view, actions }) => {
|
||||
const { alwaysShowActions, notifyOnSubtasks, projectViewActions, rowActions, ...scrollerActions } = actions;
|
||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
|
||||
const projectView = view.projectView;
|
||||
const { getOrderedGroups, setGroupOrderByProject, toggleGroup, toggleProject } = projectViewActions;
|
||||
const collection = useSessionProjectCollection({ knownDirectories: topology.knownDirectories, isVSCode: topology.isVSCode, isVisible: true });
|
||||
const [visibleSessionCountByGroup, setVisibleSessionCountByGroup] = React.useState<Map<string, number>>(new Map());
|
||||
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
|
||||
setVisibleSessionCountByGroup((current) => new Map(current).set(groupId, currentVisibleCount + 7));
|
||||
}, []);
|
||||
const resetGroupSessionLimit = React.useCallback((groupId: string) => {
|
||||
setVisibleSessionCountByGroup((current) => {
|
||||
if (!current.has(groupId)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(groupId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
||||
const projectDisplayMode = useSessionDisplayStore((state) => state.projectDisplayMode);
|
||||
const singleProjectId = useSessionDisplayStore((state) => state.singleProjectId);
|
||||
const setSingleProjectId = useSessionDisplayStore((state) => state.setSingleProjectId);
|
||||
const supportsSingleProjectMode = !topology.isVSCode && !isCapacitorApp();
|
||||
const singleProjectMode = supportsSingleProjectMode && projectDisplayMode === 'single';
|
||||
const recentSessions = useRecentSessionCollection({
|
||||
enabled: showRecentSection && !singleProjectMode,
|
||||
isVSCode: topology.isVSCode,
|
||||
pinnedSessionIds: collection.pinnedSessionIds,
|
||||
sessionOrderRanks: collection.sessionOrderRanks,
|
||||
sessions: collection.rootSessions,
|
||||
});
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState<string | null>(null);
|
||||
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
|
||||
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
||||
const [folderRename, setFolderRename] = React.useState<{ scopeKey: string; folderId: string; draft: string } | null>(null);
|
||||
const startFolderRename = React.useCallback((scopeKey: string, folder: { id: string; name: string }) => {
|
||||
setFolderRename({ scopeKey, folderId: folder.id, draft: folder.name });
|
||||
}, []);
|
||||
const setFolderRenameDraft = React.useCallback((draft: string) => {
|
||||
setFolderRename((current) => current ? { ...current, draft } : null);
|
||||
}, []);
|
||||
const clearFolderRename = React.useCallback(() => setFolderRename(null), []);
|
||||
const { expandedParents, toggleParent } = useExpandedParents();
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const selectSessionForProject = React.useCallback((sessionId: string, sessionDirectory: string | null) => {
|
||||
if (sessionId === useSessionUIStore.getState().currentSessionId) return;
|
||||
setCurrentSession(sessionId, sessionDirectory);
|
||||
}, [setCurrentSession]);
|
||||
const prefetchSession = usePrefetchSessionMessages();
|
||||
const { buildGroupedSessions, filterSessionNodesForSearch, buildGroupSearchText } = useSessionGrouping({
|
||||
homeDirectory: view.homeDirectory,
|
||||
worktreeMetadata: topology.worktreeMetadata,
|
||||
pinnedSessionIds: collection.pinnedSessionIds,
|
||||
sessionOrderRanks: collection.sessionOrderRanks,
|
||||
gitBranches: topology.gitBranches,
|
||||
isVSCode: topology.isVSCode,
|
||||
});
|
||||
const ownership = React.useMemo(
|
||||
() => createSessionOwnershipIndex(collection.sessions, topology.projects, topology.availableWorktreesByProject, topology.isVSCode, collection.archivedSessions),
|
||||
[collection.archivedSessions, collection.sessions, topology.availableWorktreesByProject, topology.isVSCode, topology.projects],
|
||||
);
|
||||
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({ ownership });
|
||||
const { projectSections, groupSearchDataByGroup, sectionsForRender, flatSectionsForRender } = useSessionSidebarSections({
|
||||
normalizedProjects: topology.projects,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject: topology.availableWorktreesByProject,
|
||||
projectRepoStatus: topology.projectRepoStatus,
|
||||
projectRootBranches: topology.projectRootBranches,
|
||||
lastRepoStatus: topology.lastRepoStatus,
|
||||
buildGroupedSessions,
|
||||
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupSearchText,
|
||||
foldersMap,
|
||||
});
|
||||
|
||||
// Second bootstrap-demand owner: the layout-level useSessionListSync keeps
|
||||
// every known directory alive at background priority even when the sidebar
|
||||
// is hidden, but only the visible collection knows which projects and
|
||||
// groups are EXPANDED. Without this owner, expanded projects bootstrapped
|
||||
// serialized at background priority (one directory at a time) instead of
|
||||
// concurrently at expanded priority.
|
||||
const childStores = useChildStoreManager();
|
||||
const expansionDemandOwner = `session-collection-expansion:${React.useId()}`;
|
||||
React.useEffect(() => {
|
||||
childStores.setBootstrapDemand(expansionDemandOwner, buildSessionBootstrapDemands({
|
||||
projectSections,
|
||||
activeProjectId: view.activeProjectId,
|
||||
collapsedProjects: projectView.collapsedProjects,
|
||||
collapsedGroups: projectView.collapsedGroups,
|
||||
currentDirectory: null,
|
||||
currentSessionDirectory: null,
|
||||
}));
|
||||
return () => childStores.clearBootstrapDemand(expansionDemandOwner);
|
||||
}, [childStores, expansionDemandOwner, projectSections, projectView.collapsedProjects, projectView.collapsedGroups, view.activeProjectId]);
|
||||
const source = view.useGroupedSections ? sectionsForRender : flatSectionsForRender;
|
||||
const sectionsForSidebarRender = React.useMemo(() => view.showInlineArchived ? source : source.map((section) => (
|
||||
section.groups.some((group) => group.isArchivedBucket)
|
||||
? { ...section, groups: section.groups.filter((group) => !group.isArchivedBucket) }
|
||||
: section
|
||||
)), [source, view.showInlineArchived]);
|
||||
const getFolderScopesForProject = React.useCallback((projectId: string) => {
|
||||
const section = flatSectionsForRender.find((entry) => entry.project.id === projectId);
|
||||
return section?.groups.find((group) => !group.isArchivedBucket)?.folderScopes ?? [];
|
||||
}, [flatSectionsForRender]);
|
||||
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
const stuckProjectHeaders = useStickyProjectHeaders({
|
||||
enabled: view.stickyZoneHeaders,
|
||||
isDesktopShellRuntime: view.isDesktopShellRuntime,
|
||||
projectSections,
|
||||
projectHeaderSentinelRefs,
|
||||
});
|
||||
useArchivedAutoFolders({
|
||||
enabled: true,
|
||||
normalizedProjects: topology.projects,
|
||||
ownership,
|
||||
isSessionsLoading: view.isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions: collection.hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading: view.isWorktreeTopologyLoading,
|
||||
unresolvedWorktreeProjectPaths: view.unresolvedWorktreeProjectPaths,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
});
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const ensureEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||
const setParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||
const refreshTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
|
||||
const retriedRef = React.useRef(new Set<string>());
|
||||
React.useEffect(() => {
|
||||
if (!github || !githubAuthChecked || !githubAuthStatus?.connected) return;
|
||||
const targets = new Map<string, { directory: string; branch: string }>();
|
||||
const now = Date.now();
|
||||
projectSections.forEach((section) => {
|
||||
if (projectView.collapsedProjects.has(section.project.id)) return;
|
||||
section.groups.forEach((group) => {
|
||||
if (group.isArchivedBucket || group.isMain) return;
|
||||
const directory = normalizePath(group.directory ?? null);
|
||||
const branch = group.branch?.trim() || topology.gitBranches.get(directory || '')?.trim();
|
||||
if (!directory || !branch) return;
|
||||
const key = getGitHubPrStatusKey(directory, branch);
|
||||
const entry = useGitHubPrStatusStore.getState().entries[key];
|
||||
const terminal = entry?.status?.pr?.state === 'closed' || entry?.status?.pr?.state === 'merged';
|
||||
const retryKey = `${directory}::${branch}`;
|
||||
const lastChecked = Math.max(entry?.lastRefreshAt ?? 0, entry?.lastDiscoveryPollAt ?? 0);
|
||||
const retry = Boolean(entry?.isInitialStatusResolved && (!entry.status?.pr || terminal) && (!retriedRef.current.has(retryKey) || now - lastChecked >= PR_NO_PR_RETRY_MS));
|
||||
if (!entry || !entry.isInitialStatusResolved || retry) {
|
||||
if (retry) retriedRef.current.add(retryKey);
|
||||
targets.set(key, { directory, branch });
|
||||
}
|
||||
});
|
||||
});
|
||||
targets.forEach((target, key) => {
|
||||
ensureEntry(key);
|
||||
setParams(key, { ...target, remoteName: null, canShow: true, github, githubAuthChecked, githubConnected: githubAuthStatus.connected });
|
||||
});
|
||||
if (targets.size) void refreshTargets([...targets.values()], { silent: true, markInitialResolved: true });
|
||||
}, [ensureEntry, github, githubAuthChecked, githubAuthStatus?.connected, projectSections, projectView.collapsedProjects, refreshTargets, setParams, topology.gitBranches]);
|
||||
const sessionOrderIndex = React.useMemo(
|
||||
() => new Map(collection.orderedSessions.map((session, index) => [session.id, index])),
|
||||
[collection.orderedSessions],
|
||||
);
|
||||
const orderedSectionsForRender = React.useMemo(
|
||||
() => sectionsForSidebarRender.map((section) => {
|
||||
const groups = getOrderedGroups(section.project.id, section.groups);
|
||||
return groups === section.groups ? section : { ...section, groups };
|
||||
}),
|
||||
[getOrderedGroups, sectionsForSidebarRender],
|
||||
);
|
||||
let selectedSingleProjectId: string | null = null;
|
||||
if (singleProjectMode) {
|
||||
if (projectSections.some((section) => section.project.id === singleProjectId)) {
|
||||
selectedSingleProjectId = singleProjectId;
|
||||
} else if (projectSections.some((section) => section.project.id === view.activeProjectId)) {
|
||||
selectedSingleProjectId = view.activeProjectId;
|
||||
} else {
|
||||
selectedSingleProjectId = projectSections[0]?.project.id ?? null;
|
||||
}
|
||||
}
|
||||
const groupProps = React.useMemo(() => ({
|
||||
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||
groupSearchDataByGroup,
|
||||
collapsedGroups: projectView.collapsedGroups,
|
||||
hideDirectoryControls: view.hideDirectoryControls,
|
||||
mobileVariant: view.mobileVariant,
|
||||
alwaysShowActions,
|
||||
activeProjectId: view.activeProjectId,
|
||||
notifyOnSubtasks,
|
||||
pinnedSessionIds: collection.pinnedSessionIds,
|
||||
sessionOrderIndex,
|
||||
expandedParents,
|
||||
editingId,
|
||||
editTitle,
|
||||
copiedSessionId,
|
||||
sessionBatchSize: singleProjectMode && !view.useGroupedSections ? 20 : undefined,
|
||||
setEditingId,
|
||||
setEditTitle,
|
||||
toggleParent,
|
||||
allowReselect: rowActions.allowReselect,
|
||||
onSessionSelected: rowActions.onSessionSelected,
|
||||
isSessionSearchOpen: rowActions.isSessionSearchOpen,
|
||||
sessionSearchQuery: rowActions.sessionSearchQuery,
|
||||
setSessionSearchQuery: rowActions.setSessionSearchQuery,
|
||||
setIsSessionSearchOpen: rowActions.setIsSessionSearchOpen,
|
||||
deleteSessionConfirm,
|
||||
setDeleteSessionConfirm,
|
||||
startFolderRename,
|
||||
setCopiedSessionId,
|
||||
startSessionWorktreeMenuLoad: actions.startSessionWorktreeMenuLoad,
|
||||
folderRename,
|
||||
setFolderRenameDraft,
|
||||
clearFolderRename,
|
||||
}), [
|
||||
collection.pinnedSessionIds,
|
||||
alwaysShowActions,
|
||||
notifyOnSubtasks,
|
||||
projectView.collapsedGroups,
|
||||
groupSearchDataByGroup,
|
||||
sessionOrderIndex,
|
||||
editTitle,
|
||||
editingId,
|
||||
expandedParents,
|
||||
folderRename,
|
||||
setFolderRenameDraft,
|
||||
clearFolderRename,
|
||||
startFolderRename,
|
||||
deleteSessionConfirm,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
actions.startSessionWorktreeMenuLoad,
|
||||
rowActions,
|
||||
toggleParent,
|
||||
view.hideDirectoryControls,
|
||||
view.hasSessionSearchQuery,
|
||||
view.activeProjectId,
|
||||
view.mobileVariant,
|
||||
view.normalizedSessionSearchQuery,
|
||||
view.useGroupedSections,
|
||||
singleProjectMode,
|
||||
]);
|
||||
const groupActions = React.useMemo(() => ({
|
||||
showMoreGroupSessions,
|
||||
resetGroupSessionLimit,
|
||||
setActiveProjectIdOnly: scrollerActions.setActiveProjectIdOnly,
|
||||
setSessionSwitcherOpen: scrollerActions.setSessionSwitcherOpen,
|
||||
openNewSessionDraft: scrollerActions.openNewSessionDraft,
|
||||
onToggleCollapsedGroup: toggleGroup,
|
||||
}), [
|
||||
resetGroupSessionLimit,
|
||||
showMoreGroupSessions,
|
||||
toggleGroup,
|
||||
scrollerActions.openNewSessionDraft,
|
||||
scrollerActions.setActiveProjectIdOnly,
|
||||
scrollerActions.setSessionSwitcherOpen,
|
||||
]);
|
||||
const chatGroup = React.useMemo<SessionGroup | null>(() => {
|
||||
if (topology.isVSCode) return null;
|
||||
const chatsRoot = getChatsRootForHome(view.homeDirectory)
|
||||
?? collection.chatSessions.map((session) => getChatsRootFromDirectory(session.directory)).find(Boolean)
|
||||
?? null;
|
||||
if (!chatsRoot) return null;
|
||||
const folderScopes = Array.from(new Set([
|
||||
chatsRoot,
|
||||
...collection.chatSessions.map((session) => normalizePath(session.directory ?? null)).filter(Boolean),
|
||||
])).filter((directory): directory is string => Boolean(directory))
|
||||
.map((directory) => ({ scopeKey: directory, directory }));
|
||||
return {
|
||||
id: 'managed-chats',
|
||||
label: '',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: chatsRoot,
|
||||
folderScopeKey: chatsRoot,
|
||||
folderScopes,
|
||||
draftTarget: 'chat',
|
||||
sessions: collection.chatSessions
|
||||
.filter((session) => !session.time?.archived && isRootSession(session))
|
||||
.map((session) => ({ session, children: (collection.childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({ session: child, children: [], worktree: null })), worktree: null })),
|
||||
};
|
||||
}, [collection.chatSessions, collection.childrenMap, topology.isVSCode, view.homeDirectory]);
|
||||
const renderChatsSection = React.useCallback(() => {
|
||||
if (!chatGroup) return null;
|
||||
return <SessionGroupSection
|
||||
{...groupProps}
|
||||
{...groupActions}
|
||||
group={chatGroup}
|
||||
groupKey="managed-chats"
|
||||
projectId={null}
|
||||
hideGroupLabel
|
||||
sessionBatchSize={20}
|
||||
scrollContainerRef={undefined}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
/>;
|
||||
}, [chatGroup, groupActions, groupProps, openSidebarMenuKey]);
|
||||
const handleOpenNewChat = React.useCallback(() => {
|
||||
useUIStore.getState().closeMainSurfaces();
|
||||
if (view.mobileVariant) scrollerActions.setSessionSwitcherOpen(false);
|
||||
scrollerActions.openNewSessionDraft({ selectedProjectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null });
|
||||
}, [scrollerActions, view.mobileVariant]);
|
||||
const recentSection = React.useMemo(() => (
|
||||
!topology.isVSCode ? <RecentSessionSection
|
||||
projects={topology.projects}
|
||||
availableWorktreesByProject={topology.availableWorktreesByProject}
|
||||
gitBranches={topology.gitBranches}
|
||||
homeDirectory={view.homeDirectory}
|
||||
hasSessionSearchQuery={view.hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={view.normalizedSessionSearchQuery}
|
||||
isDesktopShellRuntime={view.isDesktopShellRuntime}
|
||||
sessions={recentSessions}
|
||||
childrenMap={collection.childrenMap}
|
||||
pinnedSessionIds={collection.pinnedSessionIds}
|
||||
recentSessions={recentSessions}
|
||||
expandedParents={expandedParents}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
editingId={editingId}
|
||||
editTitle={editTitle}
|
||||
copiedSessionId={copiedSessionId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
mobileVariant={view.mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
setEditingId={setEditingId}
|
||||
setEditTitle={setEditTitle}
|
||||
toggleParent={toggleParent}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
allowReselect={rowActions.allowReselect}
|
||||
onSessionSelected={rowActions.onSessionSelected}
|
||||
isSessionSearchOpen={rowActions.isSessionSearchOpen}
|
||||
sessionSearchQuery={rowActions.sessionSearchQuery}
|
||||
setSessionSearchQuery={rowActions.setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={rowActions.setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||
startFolderRename={startFolderRename}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={actions.startSessionWorktreeMenuLoad}
|
||||
chatSessions={collection.chatSessions}
|
||||
renderChatsSection={renderChatsSection}
|
||||
onNewChat={handleOpenNewChat}
|
||||
showRecentSection={showRecentSection && !singleProjectMode}
|
||||
/> : null
|
||||
), [
|
||||
actions.startSessionWorktreeMenuLoad,
|
||||
alwaysShowActions,
|
||||
collection.childrenMap,
|
||||
collection.pinnedSessionIds,
|
||||
copiedSessionId,
|
||||
deleteSessionConfirm,
|
||||
editTitle,
|
||||
editingId,
|
||||
expandedParents,
|
||||
notifyOnSubtasks,
|
||||
openSidebarMenuKey,
|
||||
recentSessions,
|
||||
rowActions,
|
||||
showRecentSection,
|
||||
singleProjectMode,
|
||||
handleOpenNewChat,
|
||||
renderChatsSection,
|
||||
startFolderRename,
|
||||
toggleParent,
|
||||
topology.availableWorktreesByProject,
|
||||
topology.gitBranches,
|
||||
topology.isVSCode,
|
||||
topology.projects,
|
||||
collection.chatSessions,
|
||||
view.hasSessionSearchQuery,
|
||||
view.homeDirectory,
|
||||
view.isDesktopShellRuntime,
|
||||
view.mobileVariant,
|
||||
view.normalizedSessionSearchQuery,
|
||||
]);
|
||||
const scrollerModel = React.useMemo(() => ({
|
||||
topContent: recentSection,
|
||||
hasSharedSessions: Boolean(recentSection),
|
||||
sectionsForRender: orderedSectionsForRender,
|
||||
projectSections,
|
||||
activeProjectId: view.activeProjectId,
|
||||
singleProjectMode,
|
||||
singleProjectId: selectedSingleProjectId,
|
||||
emptyState: view.emptyState,
|
||||
searchEmptyState: view.searchEmptyState,
|
||||
projectRepoStatus: topology.projectRepoStatus,
|
||||
stuckProjectHeaders,
|
||||
projectHeaderSentinelRefs,
|
||||
state: { editingId, openSidebarMenuKey, setOpenSidebarMenuKey, visibleSessionCountByGroup },
|
||||
groupProps,
|
||||
}), [
|
||||
groupProps,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
projectSections,
|
||||
orderedSectionsForRender,
|
||||
stuckProjectHeaders,
|
||||
topology.projectRepoStatus,
|
||||
view.activeProjectId,
|
||||
view.emptyState,
|
||||
view.searchEmptyState,
|
||||
visibleSessionCountByGroup,
|
||||
recentSection,
|
||||
singleProjectMode,
|
||||
selectedSingleProjectId,
|
||||
]);
|
||||
const scrollerView = React.useMemo(() => ({
|
||||
homeDirectory: view.homeDirectory,
|
||||
collapsedProjects: projectView.collapsedProjects,
|
||||
showOnlyMainWorkspace: view.showOnlyMainWorkspace,
|
||||
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||
hideDirectoryControls: view.hideDirectoryControls,
|
||||
isDesktopShellRuntime: view.isDesktopShellRuntime,
|
||||
stickyZoneHeaders: view.stickyZoneHeaders,
|
||||
mobileVariant: view.mobileVariant,
|
||||
alwaysShowActions,
|
||||
projectSortOrder: view.projectSortOrder,
|
||||
}), [
|
||||
projectView.collapsedProjects,
|
||||
view.homeDirectory,
|
||||
view.hasSessionSearchQuery,
|
||||
view.hideDirectoryControls,
|
||||
view.isDesktopShellRuntime,
|
||||
view.mobileVariant,
|
||||
alwaysShowActions,
|
||||
view.normalizedSessionSearchQuery,
|
||||
view.projectSortOrder,
|
||||
view.showOnlyMainWorkspace,
|
||||
view.stickyZoneHeaders,
|
||||
]);
|
||||
const scrollerActionSet = React.useMemo(() => ({
|
||||
group: groupActions,
|
||||
toggleProject,
|
||||
setActiveProjectIdOnly: scrollerActions.setActiveProjectIdOnly,
|
||||
setSessionSwitcherOpen: scrollerActions.setSessionSwitcherOpen,
|
||||
openNewSessionDraft: scrollerActions.openNewSessionDraft,
|
||||
openNewWorktreeDialog: scrollerActions.openNewWorktreeDialog,
|
||||
openWorktreesPage: scrollerActions.openWorktreesPage,
|
||||
openProjectEditDialog: scrollerActions.openProjectEditDialog,
|
||||
removeProject: scrollerActions.removeProject,
|
||||
reorderProjects: scrollerActions.reorderProjects,
|
||||
setGroupOrderByProject,
|
||||
renderProjectStatusIndicator: scrollerActions.renderProjectStatusIndicator,
|
||||
setSingleProjectId,
|
||||
}), [
|
||||
groupActions,
|
||||
scrollerActions.openNewSessionDraft,
|
||||
scrollerActions.openNewWorktreeDialog,
|
||||
scrollerActions.openProjectEditDialog,
|
||||
scrollerActions.openWorktreesPage,
|
||||
scrollerActions.removeProject,
|
||||
scrollerActions.reorderProjects,
|
||||
scrollerActions.setActiveProjectIdOnly,
|
||||
scrollerActions.setSessionSwitcherOpen,
|
||||
setGroupOrderByProject,
|
||||
toggleProject,
|
||||
scrollerActions.renderProjectStatusIndicator,
|
||||
setSingleProjectId,
|
||||
]);
|
||||
return <>
|
||||
<ProjectSessionSelectionEffect
|
||||
projectSections={projectSections}
|
||||
activeProjectId={view.activeProjectId}
|
||||
initialActiveSessionByProject={actions.initialActiveSessionByProject}
|
||||
persistActiveSessionByProject={actions.persistActiveSessionByProject}
|
||||
mobileVariant={view.mobileVariant}
|
||||
openNewSessionDraft={actions.openNewSessionDraft}
|
||||
setSessionSwitcherOpen={actions.setSessionSwitcherOpen}
|
||||
sessionOwnerBySessionId={ownership.bySessionId}
|
||||
handleSessionSelect={selectSessionForProject}
|
||||
/>
|
||||
<SessionPrefetchEffect
|
||||
sortedSessions={collection.orderedSessions}
|
||||
recentSessions={recentSessions}
|
||||
prefetchSession={prefetchSession}
|
||||
/>
|
||||
<SessionProjectScroller model={scrollerModel} view={scrollerView} actions={scrollerActionSet} />
|
||||
<SessionBulkActions
|
||||
getFolderScopesForProject={getFolderScopesForProject}
|
||||
isInlineEditing={editingId !== null}
|
||||
startFolderRename={startFolderRename}
|
||||
/>
|
||||
</>;
|
||||
};
|
||||
|
||||
export const SessionProjectCollection: React.FC<SessionProjectCollectionProps> = (props) => props.view.isVisible ? <VisibleSessionProjects {...props} /> : null;
|
||||
+18
@@ -44,4 +44,22 @@ describe("buildSessionBootstrapDemands", () => {
|
||||
expect(byDirectory.get("/repo/wt-a")?.priority).toBe("expanded")
|
||||
expect(byDirectory.get("/repo/wt-b")?.priority).toBe("selected")
|
||||
})
|
||||
|
||||
test("keeps the complete known topology demanded without a visible section projection", () => {
|
||||
const demands = buildSessionBootstrapDemands({
|
||||
knownDirectories: ["/repo", "/repo/wt-a", "/repo/wt-b"],
|
||||
activeProjectDirectory: "/repo",
|
||||
activeProjectId: "project-a",
|
||||
collapsedProjects: new Set(),
|
||||
collapsedGroups: new Set(),
|
||||
currentDirectory: null,
|
||||
currentSessionDirectory: null,
|
||||
})
|
||||
|
||||
expect(demands.map(({ directory, priority }) => [directory, priority])).toEqual([
|
||||
["/repo", "active-project"],
|
||||
["/repo/wt-a", "background"],
|
||||
["/repo/wt-b", "background"],
|
||||
])
|
||||
})
|
||||
})
|
||||
+12
-5
@@ -1,5 +1,5 @@
|
||||
import type { DirectoryBootstrapDemand, DirectoryBootstrapPriority } from "@/sync/child-store"
|
||||
import { normalizePath } from "./utils"
|
||||
import { normalizePath } from "../utils"
|
||||
|
||||
type BootstrapProjectSection = {
|
||||
project: { id: string; normalizedPath: string }
|
||||
@@ -11,16 +11,18 @@ type BootstrapProjectSection = {
|
||||
}>
|
||||
}
|
||||
|
||||
const PRIORITY_RANK: Record<DirectoryBootstrapPriority, number> = {
|
||||
const PRIORITY_RANK = {
|
||||
selected: 0,
|
||||
"active-project": 1,
|
||||
expanded: 2,
|
||||
visible: 3,
|
||||
background: 4,
|
||||
}
|
||||
} satisfies Record<DirectoryBootstrapPriority, number>
|
||||
|
||||
export function buildSessionBootstrapDemands(input: {
|
||||
projectSections: BootstrapProjectSection[]
|
||||
projectSections?: BootstrapProjectSection[]
|
||||
knownDirectories?: Iterable<string>
|
||||
activeProjectDirectory?: string | null
|
||||
activeProjectId: string | null
|
||||
collapsedProjects: ReadonlySet<string>
|
||||
collapsedGroups: ReadonlySet<string>
|
||||
@@ -40,7 +42,12 @@ export function buildSessionBootstrapDemands(input: {
|
||||
byDirectory.set(normalizedDirectory, { directory: normalizedDirectory, priority, reason })
|
||||
}
|
||||
|
||||
for (const section of input.projectSections) {
|
||||
for (const directory of input.knownDirectories ?? []) {
|
||||
add(directory, "background", "known-project")
|
||||
}
|
||||
add(input.activeProjectDirectory, "active-project", "project-expanded")
|
||||
|
||||
for (const section of input.projectSections ?? []) {
|
||||
const projectExpanded = !input.collapsedProjects.has(section.project.id)
|
||||
let projectPriority: DirectoryBootstrapPriority = "background"
|
||||
if (section.project.id === input.activeProjectId) {
|
||||
@@ -0,0 +1,333 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { Event } from '@opencode-ai/sdk/v2/client';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { deriveRecentSessions } from '../recent/activitySections';
|
||||
import { applyGlobalSessionStatusEvent, replaceGlobalSessionStatusById } from '@/sync/global-session-status';
|
||||
import {
|
||||
buildSidebarSessionProjection,
|
||||
getDescendantIds,
|
||||
partitionSidebarSessions,
|
||||
projectSidebarActiveSessions,
|
||||
projectSidebarCollection,
|
||||
useRecentSessionCollection,
|
||||
} from './sessionCollection';
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: unknown) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ElementStub {}
|
||||
const documentStub: Record<string, unknown> = {
|
||||
nodeType: 9, defaultView: globalThis, activeElement: null,
|
||||
addEventListener: () => undefined, removeEventListener: () => undefined,
|
||||
};
|
||||
const container = {
|
||||
nodeType: 1, tagName: 'DIV', nodeName: 'DIV', namespaceURI: 'http://www.w3.org/1999/xhtml', ownerDocument: documentStub,
|
||||
addEventListener: () => undefined, removeEventListener: () => undefined,
|
||||
};
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
return {
|
||||
container: container as unknown as Element,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const session = (id: string, directory: string | null): Session => {
|
||||
// SAFETY: Sidebar projection reads only id, directory, and time from session fixtures.
|
||||
return {
|
||||
id,
|
||||
directory,
|
||||
time: { created: 1, updated: 1 },
|
||||
} as Session;
|
||||
};
|
||||
|
||||
describe('projectSidebarActiveSessions', () => {
|
||||
test('keeps global precedence and order, then appends missing live sessions', () => {
|
||||
const global = [session('global-b', '/workspace/b'), session('global-a', '/workspace/a')];
|
||||
const live = [session('global-a', '/workspace/a'), session('live-c', '/workspace/c')];
|
||||
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: global,
|
||||
liveSessions: live,
|
||||
knownDirectories: new Set(['/workspace/a', '/workspace/b', '/workspace/c']),
|
||||
isVSCode: false,
|
||||
}).map((entry) => entry.id)).toEqual(['global-b', 'global-a', 'live-c']);
|
||||
});
|
||||
|
||||
test('filters unknown VS Code directories', () => {
|
||||
const sessions = [session('known', '/workspace/known'), session('unknown', '/workspace/unknown')];
|
||||
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: sessions,
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/known']),
|
||||
isVSCode: true,
|
||||
}).map((entry) => entry.id)).toEqual(['known']);
|
||||
});
|
||||
|
||||
test('allows missing or unknown directories for web when no directories are known', () => {
|
||||
const sessions = [session('unknown', '/workspace/unknown'), session('empty', null)];
|
||||
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: sessions,
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(),
|
||||
isVSCode: false,
|
||||
}).map((entry) => entry.id)).toEqual(['unknown', 'empty']);
|
||||
});
|
||||
|
||||
test('keeps archived sessions despite directory filtering', () => {
|
||||
const archived = session('archived', '/workspace/unknown');
|
||||
archived.time.archived = 1;
|
||||
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: [archived],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/known']),
|
||||
isVSCode: true,
|
||||
}).map((entry) => entry.id)).toEqual(['archived']);
|
||||
});
|
||||
|
||||
test('does not replace a filtered global record with a live duplicate', () => {
|
||||
expect(projectSidebarActiveSessions({
|
||||
globalActiveSessions: [session('same', '/workspace/unknown')],
|
||||
liveSessions: [session('same', '/workspace/known')],
|
||||
knownDirectories: new Set(['/workspace/known']),
|
||||
isVSCode: true,
|
||||
})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectSidebarCollection', () => {
|
||||
test('returns the same structural projection for unchanged inputs without module caching', () => {
|
||||
const globalActiveSessions = [session('a', '/workspace/a'), session('b', '/workspace/b')];
|
||||
const input = {
|
||||
globalActiveSessions,
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a', '/workspace/b']),
|
||||
isVSCode: false,
|
||||
};
|
||||
|
||||
const beforeSelection = projectSidebarCollection(input);
|
||||
const afterSelection = projectSidebarCollection(input);
|
||||
|
||||
expect(afterSelection).toEqual(beforeSelection);
|
||||
});
|
||||
|
||||
test('rebuilds when a structural session collection input changes', () => {
|
||||
const input = {
|
||||
globalActiveSessions: [session('a', '/workspace/a')],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
};
|
||||
|
||||
const before = projectSidebarCollection(input);
|
||||
const after = projectSidebarCollection({
|
||||
...input,
|
||||
globalActiveSessions: [session('a', '/workspace/a'), session('b', '/workspace/a')],
|
||||
});
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.map((entry) => entry.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('keeps project membership independent from Recent active membership', () => {
|
||||
const input = {
|
||||
globalActiveSessions: [session('old-root', '/workspace/a')],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
};
|
||||
const projectBefore = projectSidebarCollection(input);
|
||||
const recentBefore = deriveRecentSessions(projectBefore, new Set(), 200_000_000);
|
||||
const projectAfter = projectSidebarCollection(input);
|
||||
const recentAfter = deriveRecentSessions(projectAfter, new Set(['old-root']), 200_000_000);
|
||||
|
||||
expect(projectAfter).toEqual(projectBefore);
|
||||
expect(recentBefore).toEqual([]);
|
||||
expect(recentAfter.map((entry) => entry.id)).toEqual(['old-root']);
|
||||
});
|
||||
|
||||
test('keeps managed Chats in a dedicated projection and out of project and Recent ownership', () => {
|
||||
const managed = session('managed', '/home/.config/openchamber/chats/2026-08-24/session-managed');
|
||||
const project = session('project', '/workspace/a');
|
||||
const projects = projectSidebarCollection({
|
||||
globalActiveSessions: [managed, project],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
});
|
||||
|
||||
expect(projects.map((entry) => entry.id)).toEqual(['project']);
|
||||
expect(partitionSidebarSessions([managed, project], false).chatSessions.map((entry) => entry.id)).toEqual(['managed']);
|
||||
expect(deriveRecentSessions(projects, new Set(['managed', 'project']), 200_000_000)
|
||||
.map((entry) => entry.id)).toEqual(['project']);
|
||||
});
|
||||
|
||||
test('keeps managed Chats out of the VS Code sidebar', () => {
|
||||
const managed = session('managed', '/home/.config/openchamber/chats/2026-08-24/session-managed');
|
||||
|
||||
expect(partitionSidebarSessions([managed], true)).toEqual({ projectSessions: [], chatSessions: [] });
|
||||
expect(projectSidebarCollection({
|
||||
globalActiveSessions: [managed],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(),
|
||||
isVSCode: true,
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
test('excludes a /btw fork before project ownership and restores it when the marker is removed', () => {
|
||||
const fork = {
|
||||
...session('fork', '/home/.config/openchamber/chats/2026-08-24/session-fork'),
|
||||
metadata: { openchamber: { kind: 'btw', originalSessionID: 'parent' } },
|
||||
};
|
||||
const project = session('project', '/workspace/a');
|
||||
const input = {
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
};
|
||||
|
||||
expect(projectSidebarCollection({ ...input, globalActiveSessions: [fork, project] }).map((entry) => entry.id)).toEqual(['project']);
|
||||
expect(partitionSidebarSessions([fork], false).chatSessions).toEqual([]);
|
||||
|
||||
const promoted = {
|
||||
...fork,
|
||||
metadata: { openchamber: {} },
|
||||
};
|
||||
expect(partitionSidebarSessions([promoted], false).chatSessions.map((entry) => entry.id)).toEqual(['fork']);
|
||||
});
|
||||
|
||||
test('keeps a ranked managed root and its active child in the Chats hierarchy', () => {
|
||||
const managedRoot = { ...session('managed-root', '/home/.config/openchamber/chats/2026-08-24/session-root'), time: { created: 1, updated: 1 } };
|
||||
const managedChild = {
|
||||
...session('managed-child', '/home/.config/openchamber/chats/2026-08-24/session-root'),
|
||||
parentID: 'managed-root',
|
||||
time: { created: 2, updated: 2 },
|
||||
};
|
||||
const projectRoot = { ...session('project-root', '/workspace/a'), time: { created: 3, updated: 3 } };
|
||||
|
||||
const projection = buildSidebarSessionProjection({
|
||||
globalActiveSessions: [projectRoot, managedRoot, managedChild],
|
||||
liveSessions: [],
|
||||
knownDirectories: new Set(['/workspace/a']),
|
||||
isVSCode: false,
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderRanks: new Map([['managed-root', 10]]),
|
||||
});
|
||||
|
||||
expect(projection.projectSessions.map((entry) => entry.id)).toEqual(['project-root']);
|
||||
expect(projection.chatSessions.map((entry) => entry.id)).toEqual(['managed-root', 'managed-child']);
|
||||
expect(projection.orderedSessions.map((entry) => entry.id)).toEqual(['managed-root', 'managed-child', 'project-root']);
|
||||
expect(projection.childrenMap.get('managed-root')?.map((entry) => entry.id)).toEqual(['managed-child']);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('useRecentSessionCollection', () => {
|
||||
test('updates mounted Recent membership when global active status changes', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const oldSession = { ...session('old-root', '/workspace/a'), time: { created: 1, updated: 1 } };
|
||||
let renderedIds: string[] = [];
|
||||
let renderCount = 0;
|
||||
let timeReadCount = 0;
|
||||
Object.defineProperty(oldSession, 'time', {
|
||||
get: () => {
|
||||
timeReadCount += 1;
|
||||
return { created: 1, updated: 1 };
|
||||
},
|
||||
});
|
||||
timeReadCount = 0;
|
||||
const Harness = () => {
|
||||
renderCount += 1;
|
||||
const recent = useRecentSessionCollection({
|
||||
enabled: true,
|
||||
isVSCode: false,
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderRanks: new Map(),
|
||||
sessions: [oldSession],
|
||||
});
|
||||
renderedIds = recent.map((entry) => entry.id);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
replaceGlobalSessionStatusById(new Map());
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(renderedIds).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
// SAFETY: This fixture matches the SDK event shape consumed by the status event reducer.
|
||||
applyGlobalSessionStatusEvent('/workspace/a', {
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'old-root', status: { type: 'busy' } },
|
||||
} as Event);
|
||||
});
|
||||
expect(renderedIds).toEqual(['old-root']);
|
||||
const activeRenderCount = renderCount;
|
||||
const activeDeriveOperationCount = timeReadCount;
|
||||
|
||||
await act(async () => {
|
||||
// SAFETY: This fixture matches the SDK event shape consumed by the status event reducer.
|
||||
applyGlobalSessionStatusEvent('/other-workspace', {
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'old-root', status: { type: 'retry', attempt: 2, message: 'waiting' } },
|
||||
} as Event);
|
||||
});
|
||||
expect(renderCount).toBe(activeRenderCount);
|
||||
expect(timeReadCount).toBe(activeDeriveOperationCount);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
replaceGlobalSessionStatusById(new Map());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getDescendantIds', () => {
|
||||
test('returns a depth-first subtree without exposing session entities', () => {
|
||||
const childA = session('child-a', '/workspace/a');
|
||||
const grandchild = session('grandchild', '/workspace/a');
|
||||
const childB = session('child-b', '/workspace/a');
|
||||
const childrenMap = new Map([
|
||||
['root', [childA, childB]],
|
||||
['child-a', [grandchild]],
|
||||
]);
|
||||
|
||||
expect(getDescendantIds(childrenMap, 'root'))
|
||||
.toEqual(['child-a', 'grandchild', 'child-b']);
|
||||
});
|
||||
|
||||
test('cuts a parent cycle with deterministic unique descendants and excludes the root', () => {
|
||||
const childA = session('a', '/workspace/a');
|
||||
const childB = session('b', '/workspace/a');
|
||||
const childC = session('c', '/workspace/a');
|
||||
const childrenMap = new Map([
|
||||
['root', [childA]],
|
||||
['a', [childB, childC]],
|
||||
['b', [childA]],
|
||||
]);
|
||||
|
||||
expect(getDescendantIds(childrenMap, 'root')).toEqual(['a', 'b', 'c']);
|
||||
expect(new Set(getDescendantIds(childrenMap, 'root')).size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useAllLiveSessions } from '@/sync/sync-context';
|
||||
import {
|
||||
EMPTY_SESSION_ORDER_RANKS,
|
||||
orderSessionsByLifecycleScopes,
|
||||
useSessionOrderingStore,
|
||||
} from '@/sync/session-ordering';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { deriveRecentSessions } from '../recent/activitySections';
|
||||
import { normalizePath } from '../utils';
|
||||
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { isBtwSession } from '@/lib/sessionBtwMetadata';
|
||||
import type { GlobalSessionStructure } from '@/stores/globalSessionStructure';
|
||||
import { countSyncPerformance } from '@/sync/performance-diagnostics';
|
||||
|
||||
type ProjectSidebarActiveSessionsArgs = {
|
||||
globalActiveSessions: Session[];
|
||||
liveSessions: Session[];
|
||||
knownDirectories: Set<string>;
|
||||
isVSCode: boolean;
|
||||
};
|
||||
|
||||
type SidebarSessionPartitions = {
|
||||
projectSessions: Session[];
|
||||
chatSessions: Session[];
|
||||
};
|
||||
|
||||
const parentIdOf = (session: Session): string | null => {
|
||||
// SAFETY: OpenCode session payloads expose parentID although the SDK base Session omits it.
|
||||
return (session as Session & { parentID?: string | null }).parentID ?? null;
|
||||
};
|
||||
|
||||
// This boundary owns session visibility before Recent or projects take
|
||||
// ownership. Temporary /btw forks never leak into any sidebar projection.
|
||||
export const partitionSidebarSessions = (
|
||||
sessions: readonly Session[],
|
||||
isVSCode: boolean,
|
||||
): SidebarSessionPartitions => {
|
||||
const projectSessions: Session[] = [];
|
||||
const chatSessions: Session[] = [];
|
||||
for (const session of sessions) {
|
||||
if (isBtwSession(session)) continue;
|
||||
if (isChatDirectoryPath(session.directory)) {
|
||||
if (isVSCode) continue;
|
||||
chatSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
projectSessions.push(session);
|
||||
}
|
||||
return { projectSessions, chatSessions };
|
||||
};
|
||||
|
||||
const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet<string> = new Set();
|
||||
|
||||
const isKnownActiveSessionDirectory = (
|
||||
session: Session,
|
||||
knownDirectories: Set<string>,
|
||||
isVSCode: boolean,
|
||||
): boolean => {
|
||||
if (session.time?.archived) return true;
|
||||
const directory = normalizePath(resolveGlobalSessionDirectory(session))?.toLowerCase();
|
||||
if (!directory) return !isVSCode;
|
||||
if (knownDirectories.size === 0) return !isVSCode;
|
||||
return knownDirectories.has(directory);
|
||||
};
|
||||
|
||||
// Global sessions provide complete sidebar coverage; initialized directory
|
||||
// stores only fill gaps until the global cache catches up.
|
||||
export const projectSidebarActiveSessions = ({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
}: ProjectSidebarActiveSessionsArgs): Session[] => {
|
||||
const sessions = [...globalActiveSessions];
|
||||
const knownIds = new Set(globalActiveSessions.map((session) => session.id));
|
||||
|
||||
for (const session of liveSessions) {
|
||||
if (knownIds.has(session.id)) continue;
|
||||
sessions.push(session);
|
||||
}
|
||||
|
||||
return partitionSidebarSessions(sessions, isVSCode).projectSessions
|
||||
.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
||||
};
|
||||
|
||||
export const projectSidebarCollection = (args: ProjectSidebarActiveSessionsArgs): Session[] => {
|
||||
return projectSidebarActiveSessions(args);
|
||||
};
|
||||
|
||||
const mergeSidebarSessionSources = (
|
||||
globalActiveSessions: readonly Session[],
|
||||
liveSessions: readonly Session[],
|
||||
): Session[] => {
|
||||
const sessions = [...globalActiveSessions];
|
||||
const knownIds = new Set(globalActiveSessions.map((session) => session.id));
|
||||
for (const session of liveSessions) {
|
||||
if (knownIds.has(session.id)) continue;
|
||||
knownIds.add(session.id);
|
||||
sessions.push(session);
|
||||
}
|
||||
return sessions;
|
||||
};
|
||||
|
||||
// The collection owns hierarchy membership. Consumers receive this narrow
|
||||
// resolver instead of retaining the collection's mutable indexing detail.
|
||||
export const getDescendantIds = (
|
||||
childrenMap: ReadonlyMap<string, readonly Session[]>,
|
||||
sessionId: string,
|
||||
): string[] => {
|
||||
const descendants: string[] = [];
|
||||
const visited = new Set<string>([sessionId]);
|
||||
const visit = (parentId: string): void => {
|
||||
for (const child of childrenMap.get(parentId) ?? []) {
|
||||
if (visited.has(child.id)) continue;
|
||||
visited.add(child.id);
|
||||
descendants.push(child.id);
|
||||
visit(child.id);
|
||||
}
|
||||
};
|
||||
visit(sessionId);
|
||||
return descendants;
|
||||
};
|
||||
|
||||
type SidebarSessionProjectionArgs = ProjectSidebarActiveSessionsArgs & {
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderRanks: ReadonlyMap<string, number>;
|
||||
};
|
||||
|
||||
type SidebarSessionStructureArgs = Omit<ProjectSidebarActiveSessionsArgs, 'globalActiveSessions'> & {
|
||||
globalActiveSessions?: readonly Session[];
|
||||
globalStructure?: GlobalSessionStructure;
|
||||
};
|
||||
|
||||
const buildSidebarSessionStructure = ({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
globalStructure,
|
||||
}: SidebarSessionStructureArgs) => {
|
||||
countSyncPerformance('sidebarStructureBuilds');
|
||||
const indexedGlobalSessions = globalActiveSessions ?? [];
|
||||
const visibleSessions = mergeSidebarSessionSources(indexedGlobalSessions, liveSessions);
|
||||
const partition = partitionSidebarSessions(visibleSessions, isVSCode);
|
||||
const projectSessions = partition.projectSessions
|
||||
.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
||||
const sessions = [...projectSessions, ...partition.chatSessions];
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
const projectSessionIds = new Set(projectSessions.map((session) => session.id));
|
||||
const indexedRootIds = globalStructure?.activeRootIds ?? [];
|
||||
const indexedRootIdSet = new Set(indexedRootIds);
|
||||
const rootSessions = [
|
||||
...indexedRootIds.flatMap((sessionId) => {
|
||||
if (!projectSessionIds.has(sessionId)) return [];
|
||||
const session = sessionById.get(sessionId);
|
||||
return session ? [session] : [];
|
||||
}),
|
||||
...projectSessions.filter((session) => (
|
||||
!indexedRootIdSet.has(session.id) && !parentIdOf(session)
|
||||
)),
|
||||
];
|
||||
return {
|
||||
chatSessionIds: new Set(partition.chatSessions.map((session) => session.id)),
|
||||
projectSessions,
|
||||
rootSessions,
|
||||
sessionById,
|
||||
sessions,
|
||||
hierarchy: globalStructure ? {
|
||||
rootIds: globalStructure.activeRootIds,
|
||||
childrenByParentId: globalStructure.activeChildrenByParentId,
|
||||
} : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const orderSidebarSessionStructure = (
|
||||
structure: ReturnType<typeof buildSidebarSessionStructure>,
|
||||
pinnedSessionIds: Set<string>,
|
||||
sessionOrderRanks: ReadonlyMap<string, number>,
|
||||
) => {
|
||||
const orderedSessions = orderSessionsByLifecycleScopes(
|
||||
structure.sessions,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
structure.hierarchy,
|
||||
);
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
for (const session of orderedSessions) {
|
||||
const parentID = parentIdOf(session);
|
||||
if (!parentID) continue;
|
||||
const siblings = childrenMap.get(parentID) ?? [];
|
||||
siblings.push(session);
|
||||
childrenMap.set(parentID, siblings);
|
||||
}
|
||||
return {
|
||||
chatSessions: orderedSessions.filter((session) => structure.chatSessionIds.has(session.id)),
|
||||
childrenMap,
|
||||
orderedSessions,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSidebarSessionProjection = ({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
}: SidebarSessionProjectionArgs) => {
|
||||
const structure = buildSidebarSessionStructure({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
});
|
||||
const ordering = orderSidebarSessionStructure(structure, pinnedSessionIds, sessionOrderRanks);
|
||||
return {
|
||||
...ordering,
|
||||
projectSessions: structure.projectSessions,
|
||||
sessionById: structure.sessionById,
|
||||
};
|
||||
};
|
||||
|
||||
type UseSessionProjectCollectionArgs = {
|
||||
knownDirectories: Set<string>;
|
||||
isVSCode: boolean;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
// The collection owns the global-first/live-gap merge and lifecycle ordering.
|
||||
// Selection state intentionally never enters this boundary: rows subscribe to
|
||||
// active state themselves, leaving this projection referentially stable.
|
||||
export const useSessionProjectCollection = ({
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
isVisible,
|
||||
}: UseSessionProjectCollectionArgs) => {
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const globalStructure = useGlobalSessionsStore((state) => state.structure);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||
const sessionOrderRanks = useSessionOrderingStore(React.useCallback(
|
||||
(state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS,
|
||||
[isVisible],
|
||||
));
|
||||
const structure = React.useMemo(() => buildSidebarSessionStructure({
|
||||
globalActiveSessions,
|
||||
globalStructure,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
}), [globalActiveSessions, globalStructure, isVSCode, knownDirectories, liveSessions]);
|
||||
const ordering = React.useMemo(
|
||||
() => orderSidebarSessionStructure(structure, pinnedSessionIds, sessionOrderRanks),
|
||||
[pinnedSessionIds, sessionOrderRanks, structure],
|
||||
);
|
||||
const { chatSessions, orderedSessions } = ordering;
|
||||
const sessions = structure.projectSessions;
|
||||
const sessionById = React.useMemo(() => new Map(
|
||||
[...structure.sessions, ...archivedSessions].map((session) => [session.id, session]),
|
||||
), [archivedSessions, structure.sessions]);
|
||||
const childrenMap = React.useMemo(() => {
|
||||
const children = new Map(ordering.childrenMap);
|
||||
for (const session of archivedSessions) {
|
||||
// SAFETY: OpenCode's session records carry parentID for sub-session
|
||||
// hierarchy; the SDK's base Session type does not currently expose it.
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) continue;
|
||||
const siblings = children.get(parentID) ?? [];
|
||||
siblings.push(session);
|
||||
children.set(parentID, siblings);
|
||||
}
|
||||
return children;
|
||||
}, [archivedSessions, ordering.childrenMap]);
|
||||
const getDescendantIdsForAction = React.useCallback(
|
||||
(sessionId: string, options: { includeArchived: boolean }) => getDescendantIds(childrenMap, sessionId)
|
||||
.filter((id) => options.includeArchived || !sessionById.get(id)?.time?.archived),
|
||||
[childrenMap, sessionById],
|
||||
);
|
||||
|
||||
return {
|
||||
archivedSessions,
|
||||
childrenMap,
|
||||
chatSessions,
|
||||
getDescendantIds: getDescendantIdsForAction,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
liveSessions,
|
||||
orderedSessions,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
sessions,
|
||||
rootSessions: structure.rootSessions,
|
||||
};
|
||||
};
|
||||
|
||||
type UseRecentSessionCollectionArgs = {
|
||||
enabled: boolean;
|
||||
isVSCode: boolean;
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderRanks: ReadonlyMap<string, number>;
|
||||
sessions: Session[];
|
||||
};
|
||||
|
||||
// Recent is a separate high-frequency collection view. Its active membership
|
||||
// never participates in project ownership or project section projection.
|
||||
export const useRecentSessionCollection = ({
|
||||
enabled,
|
||||
isVSCode,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
sessions,
|
||||
}: UseRecentSessionCollectionArgs): Session[] => {
|
||||
const activeSessionIdSet = useGlobalSessionStatusStore(
|
||||
React.useCallback(
|
||||
(state) => enabled && !isVSCode ? state.activeSessionIds : EMPTY_ACTIVE_SESSION_IDS,
|
||||
[enabled, isVSCode],
|
||||
),
|
||||
);
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (!enabled || isVSCode) return [];
|
||||
countSyncPerformance('recentCandidatesVisited', sessions.length);
|
||||
return orderSessionsByLifecycleScopes(
|
||||
deriveRecentSessions(sessions, activeSessionIdSet),
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
);
|
||||
}, [activeSessionIdSet, enabled, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions]);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildKnownSessionDirectories } from './sessionListDirectories';
|
||||
|
||||
describe('buildKnownSessionDirectories', () => {
|
||||
test('normalizes project roots and optionally includes worktrees', () => {
|
||||
const worktrees = new Map([
|
||||
['/repo', [{ path: '/repo/worktree', projectDirectory: '/repo', branch: 'worktree', label: 'worktree' }]],
|
||||
]);
|
||||
|
||||
expect([...buildKnownSessionDirectories([{ path: '/Repo' }], worktrees)]).toEqual([
|
||||
'/repo',
|
||||
'/repo/worktree',
|
||||
]);
|
||||
expect([...buildKnownSessionDirectories([{ path: '/Repo' }], worktrees, { includeWorktrees: false })]).toEqual([
|
||||
'/repo',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
export const buildKnownSessionDirectories = (
|
||||
projects: Array<{ path: string }>,
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
||||
options?: { includeWorktrees?: boolean },
|
||||
): Set<string> => {
|
||||
const directories = new Set<string>();
|
||||
for (const project of projects) {
|
||||
const normalized = normalizePath(project.path)?.toLowerCase();
|
||||
if (normalized) directories.add(normalized);
|
||||
}
|
||||
if (options?.includeWorktrees === false) {
|
||||
return directories;
|
||||
}
|
||||
for (const worktrees of availableWorktreesByProject.values()) {
|
||||
for (const worktree of worktrees) {
|
||||
const normalized = normalizePath(worktree.path)?.toLowerCase();
|
||||
if (normalized) directories.add(normalized);
|
||||
}
|
||||
}
|
||||
return directories;
|
||||
};
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from './authoritativeSessionCleanup';
|
||||
|
||||
// SAFETY: cleanup identity tests only consume the SDK session ID and directory fields.
|
||||
const session = (id: string, directory = '/repo'): Session => ({ id, directory }) as Session;
|
||||
|
||||
const cleanups: Array<{ runtimeKey: string; directory: string; sessionId: string }> = [];
|
||||
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
|
||||
mock.module('@/sync/session-deletion-cleanup', () => ({
|
||||
cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => cleanups.push(identity),
|
||||
}));
|
||||
const { useAuthoritativeSessionCleanup } = await import('./useAuthoritativeSessionCleanup');
|
||||
|
||||
const CleanupProbe: React.FC<{ sessions: Session[]; revision: number }> = ({ sessions, revision }) => {
|
||||
useAuthoritativeSessionCleanup({ enabled: true, hasAuthoritativeGlobalSessions: true, sessions });
|
||||
return React.createElement('span', null, revision);
|
||||
};
|
||||
|
||||
describe('authoritative session cleanup', () => {
|
||||
let root: Root;
|
||||
let dom: ReturnType<typeof installHookTestDom>;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanups.length = 0;
|
||||
dom = installHookTestDom();
|
||||
root = createRoot(dom.container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
dom.restore();
|
||||
});
|
||||
|
||||
test('does not infer deletion from the first authoritative startup snapshot', () => {
|
||||
const current = buildAuthoritativeSessionIdentityMap([]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(null, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('finds sessions omitted after an established authoritative baseline', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([
|
||||
session('deleted'),
|
||||
session('retained'),
|
||||
]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('retained')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([
|
||||
{ directory: '/repo', sessionId: 'deleted' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('treats archive membership as retained authority', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('archived')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([
|
||||
// SAFETY: cleanup identity tests only consume the SDK session ID and directory fields.
|
||||
{ ...session('archived'), time: { archived: 10 } } as Session,
|
||||
]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat a directory move as session deletion', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-a')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-b')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('uses the first mounted complete snapshot as a baseline, then cleans an omission once', () => {
|
||||
const baseline = [session('deleted'), session('retained')];
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: baseline, revision: 0 })));
|
||||
expect(cleanups).toEqual([]);
|
||||
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [session('retained')], revision: 1 })));
|
||||
expect(cleanups).toEqual([{ runtimeKey: 'runtime', directory: '/repo', sessionId: 'deleted' }]);
|
||||
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [session('retained')], revision: 2 })));
|
||||
expect(cleanups).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('retains archive and move identities, preserves the same-array baseline on unrelated rerender, and resets on remount', () => {
|
||||
const baseline = [session('session', '/repo-a')];
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: baseline, revision: 0 })));
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: baseline, revision: 1 })));
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [{ ...session('session', '/repo-a'), time: { created: 0, updated: 0, archived: 1 } }], revision: 2 })));
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [session('session', '/repo-b')], revision: 3 })));
|
||||
expect(cleanups).toEqual([]);
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(dom.container);
|
||||
act(() => root.render(React.createElement(CleanupProbe, { sessions: [], revision: 4 })));
|
||||
expect(cleanups).toEqual([]);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { cleanupPersistedSessionState } from '@/sync/session-deletion-cleanup';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from '../authoritativeSessionCleanup';
|
||||
} from './authoritativeSessionCleanup';
|
||||
|
||||
export const useAuthoritativeSessionCleanup = (args: {
|
||||
enabled?: boolean;
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
type Event =
|
||||
| { type: 'scheduled-task-ran' }
|
||||
| { type: 'session-created'; directory: string };
|
||||
|
||||
type LifecycleState = {
|
||||
demands: Array<{ owner: string; directories: string[] }>;
|
||||
clearedOwners: string[];
|
||||
globalRefreshes: number;
|
||||
directoryRefreshes: string[][];
|
||||
cleanupInputs: Array<{ enabled: boolean; hasAuthoritativeGlobalSessions: boolean; sessionCount: number; sessions: unknown[] }>;
|
||||
listener: ((event: Event) => void) | null;
|
||||
subscriptions: number;
|
||||
unsubscriptions: number;
|
||||
};
|
||||
const state: LifecycleState = {
|
||||
demands: [],
|
||||
clearedOwners: [],
|
||||
globalRefreshes: 0,
|
||||
directoryRefreshes: [],
|
||||
cleanupInputs: [],
|
||||
listener: null,
|
||||
subscriptions: 0,
|
||||
unsubscriptions: 0,
|
||||
};
|
||||
const childStores = {
|
||||
setBootstrapDemand: (owner: string, demands: Array<{ directory: string }>) => {
|
||||
state.demands.push({ owner, directories: demands.map((demand) => demand.directory) });
|
||||
},
|
||||
clearBootstrapDemand: (owner: string) => state.clearedOwners.push(owner),
|
||||
};
|
||||
type GlobalSessionsState = { activeSessions: never[]; archivedSessions: never[]; status: 'ready' };
|
||||
const globalSessions: GlobalSessionsState = { activeSessions: [], archivedSessions: [], status: 'ready' };
|
||||
|
||||
mock.module('@/sync/sync-context', () => ({
|
||||
useChildStoreManager: () => childStores,
|
||||
}));
|
||||
mock.module('@/sync/sync-refs', () => ({ getAllSyncSessions: () => [] }));
|
||||
mock.module('@/stores/useGlobalSessionsStore', () => ({
|
||||
useGlobalSessionsStore: <T,>(selector: (value: GlobalSessionsState) => T): T => selector(globalSessions),
|
||||
refreshGlobalSessions: () => { state.globalRefreshes += 1; },
|
||||
refreshGlobalSessionsForDirectories: (directories: string[]) => { state.directoryRefreshes.push(directories); },
|
||||
}));
|
||||
mock.module('@/lib/openchamberEvents', () => ({
|
||||
subscribeOpenchamberEvents: (listener: (event: Event) => void) => {
|
||||
state.subscriptions += 1;
|
||||
state.listener = listener;
|
||||
return () => {
|
||||
state.unsubscriptions += 1;
|
||||
state.listener = null;
|
||||
};
|
||||
},
|
||||
}));
|
||||
mock.module('./useAuthoritativeSessionCleanup', () => ({
|
||||
useAuthoritativeSessionCleanup: (input: { enabled: boolean; hasAuthoritativeGlobalSessions: boolean; sessions: unknown[] }) => {
|
||||
state.cleanupInputs.push({
|
||||
enabled: input.enabled,
|
||||
hasAuthoritativeGlobalSessions: input.hasAuthoritativeGlobalSessions,
|
||||
sessionCount: input.sessions.length,
|
||||
sessions: input.sessions,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const { useSessionListSync } = await import('./useSessionListSync');
|
||||
|
||||
const projects = [{ id: 'project', path: '/project' }];
|
||||
const worktree: WorktreeMetadata = { path: '/worktree', projectDirectory: '/project', branch: 'feature', label: 'feature' };
|
||||
|
||||
const LifecycleProbe: React.FC<{ isVSCode: boolean }> = ({ isVSCode }) => {
|
||||
useSessionListSync({ isVSCode });
|
||||
return null;
|
||||
};
|
||||
|
||||
const LifecycleHarness: React.FC<{ isVSCode: boolean; branch: 'hidden' | 'visible' | 'compact-sessions' | 'compact-chat' | 'expanded' }> = ({ isVSCode, branch }) => <>
|
||||
<LifecycleProbe isVSCode={isVSCode} />
|
||||
<span>{branch}</span>
|
||||
</>;
|
||||
|
||||
describe('useSessionListSync', () => {
|
||||
let root: Root;
|
||||
let dom: ReturnType<typeof installHookTestDom>;
|
||||
|
||||
beforeEach(() => {
|
||||
state.demands = [];
|
||||
state.clearedOwners = [];
|
||||
state.globalRefreshes = 0;
|
||||
state.directoryRefreshes = [];
|
||||
state.cleanupInputs = [];
|
||||
state.listener = null;
|
||||
state.subscriptions = 0;
|
||||
state.unsubscriptions = 0;
|
||||
dom = installHookTestDom();
|
||||
root = createRoot(dom.container);
|
||||
useProjectsStore.setState({ projects, activeProjectId: 'project' });
|
||||
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||
useSessionUIStore.setState({ currentSessionDirectory: null, availableWorktreesByProject: new Map() });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
dom.restore();
|
||||
});
|
||||
|
||||
test('leaves initial global refresh to the root poller while publishing complete demand', () => {
|
||||
act(() => useSessionUIStore.setState({ availableWorktreesByProject: new Map([['/project', [worktree]]]) }));
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.demands).toHaveLength(1);
|
||||
expect(state.demands[0]?.directories).toEqual(['/project', '/worktree']);
|
||||
expect(state.directoryRefreshes).toEqual([]);
|
||||
expect(state.subscriptions).toBe(1);
|
||||
expect(state.cleanupInputs.at(-1)).toEqual({ enabled: true, hasAuthoritativeGlobalSessions: true, sessionCount: 0, sessions: [] });
|
||||
});
|
||||
|
||||
test('refreshes every VS Code directory on first mount and only topology additions afterward', () => {
|
||||
act(() => root.render(<LifecycleProbe isVSCode />));
|
||||
act(() => useProjectsStore.setState({ projects: [...projects, { id: 'added', path: '/added' }] }));
|
||||
|
||||
expect(state.directoryRefreshes).toEqual([['/project'], ['/added']]);
|
||||
});
|
||||
|
||||
test('coalesces control events and clears the listener, timeout, and demand on unmount', async () => {
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
state.listener?.({ type: 'session-created', directory: '/created-a' });
|
||||
state.listener?.({ type: 'session-created', directory: '/created-b' });
|
||||
state.listener?.({ type: 'scheduled-task-ran' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
expect(state.globalRefreshes).toBe(1);
|
||||
expect(state.directoryRefreshes).toEqual([]);
|
||||
|
||||
const owner = state.demands[0]?.owner;
|
||||
act(() => root.unmount());
|
||||
expect(state.unsubscriptions).toBe(1);
|
||||
expect(state.clearedOwners).toEqual([owner]);
|
||||
});
|
||||
|
||||
test('does not duplicate lifecycle ownership when a hidden MainLayout or compact VS Code view rerenders', () => {
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
const cleanupSessions = state.cleanupInputs.at(-1)?.sessions;
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.subscriptions).toBe(1);
|
||||
expect(state.demands).toHaveLength(1);
|
||||
expect(state.cleanupInputs.at(-1)?.sessions).toBe(cleanupSessions);
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(dom.container);
|
||||
act(() => root.render(<LifecycleProbe isVSCode />));
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.subscriptions).toBe(2);
|
||||
expect(state.unsubscriptions).toBe(1);
|
||||
});
|
||||
|
||||
test('cancels a pending control-event refresh before a layout remount', async () => {
|
||||
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||
state.listener?.({ type: 'session-created', directory: '/created' });
|
||||
act(() => root.unmount());
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||
expect(state.directoryRefreshes).toEqual([]);
|
||||
expect(state.unsubscriptions).toBe(1);
|
||||
});
|
||||
|
||||
test('binds MainLayout ownership to real Store worktrees without duplicating lifecycle work across branches', () => {
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project', path: '/project' }],
|
||||
activeProjectId: 'project',
|
||||
});
|
||||
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||
useSessionUIStore.setState({
|
||||
currentSessionDirectory: '/worktree',
|
||||
availableWorktreesByProject: new Map([['/project', [worktree]]]),
|
||||
});
|
||||
|
||||
act(() => root.render(<LifecycleHarness isVSCode={false} branch="hidden" />));
|
||||
act(() => root.render(<LifecycleHarness isVSCode={false} branch="visible" />));
|
||||
act(() => root.render(<LifecycleHarness isVSCode={false} branch="expanded" />));
|
||||
|
||||
expect(state.demands).toHaveLength(1);
|
||||
expect(state.demands[0]?.directories).toEqual(['/project', '/worktree']);
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.subscriptions).toBe(1);
|
||||
});
|
||||
|
||||
test('binds VS Code ownership to Store projects without worktrees and refreshes its first directories once', () => {
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project', path: '/project' }],
|
||||
activeProjectId: 'project',
|
||||
});
|
||||
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||
useSessionUIStore.setState({
|
||||
currentSessionDirectory: '/project',
|
||||
availableWorktreesByProject: new Map([['/project', [worktree]]]),
|
||||
});
|
||||
|
||||
act(() => root.render(<LifecycleHarness isVSCode branch="compact-sessions" />));
|
||||
act(() => root.render(<LifecycleHarness isVSCode branch="compact-chat" />));
|
||||
act(() => root.unmount());
|
||||
root = createRoot(dom.container);
|
||||
act(() => root.render(<LifecycleHarness isVSCode branch="compact-sessions" />));
|
||||
|
||||
expect(state.demands.map((demand) => demand.directories)).toEqual([['/project'], ['/project']]);
|
||||
expect(state.directoryRefreshes).toEqual([['/project'], ['/project']]);
|
||||
expect(state.globalRefreshes).toBe(0);
|
||||
expect(state.subscriptions).toBe(2);
|
||||
expect(state.unsubscriptions).toBe(1);
|
||||
});
|
||||
|
||||
test('does not rerender VS Code lifecycle ownership for worktree-map-only changes', () => {
|
||||
useProjectsStore.setState({
|
||||
projects: [{ id: 'project', path: '/project' }],
|
||||
activeProjectId: 'project',
|
||||
});
|
||||
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||
useSessionUIStore.setState({
|
||||
currentSessionDirectory: '/project',
|
||||
availableWorktreesByProject: new Map([['/project', [worktree]]]),
|
||||
});
|
||||
|
||||
act(() => root.render(<LifecycleHarness isVSCode branch="compact-sessions" />));
|
||||
const cleanupInputCount = state.cleanupInputs.length;
|
||||
const demandCount = state.demands.length;
|
||||
const directoryRefreshCount = state.directoryRefreshes.length;
|
||||
const subscriptionCount = state.subscriptions;
|
||||
|
||||
act(() => useSessionUIStore.setState({
|
||||
availableWorktreesByProject: new Map([['/project', [{ ...worktree, path: '/other-worktree' }]]]),
|
||||
}));
|
||||
|
||||
expect(state.cleanupInputs).toHaveLength(cleanupInputCount);
|
||||
expect(state.demands).toHaveLength(demandCount);
|
||||
expect(state.directoryRefreshes).toHaveLength(directoryRefreshCount);
|
||||
expect(state.subscriptions).toBe(subscriptionCount);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
import { refreshGlobalSessions, refreshGlobalSessionsForDirectories, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { buildSessionBootstrapDemands } from './sessionBootstrapDemands';
|
||||
import { buildKnownSessionDirectories } from './sessionListDirectories';
|
||||
import { useAuthoritativeSessionCleanup } from './useAuthoritativeSessionCleanup';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
const EMPTY_WORKTREES_BY_PROJECT = new Map();
|
||||
|
||||
type UseSessionListSyncOptions = {
|
||||
isVSCode: boolean;
|
||||
};
|
||||
|
||||
export const useSessionListSync = ({
|
||||
isVSCode,
|
||||
}: UseSessionListSyncOptions) => {
|
||||
const childStores = useChildStoreManager();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => isVSCode ? EMPTY_WORKTREES_BY_PROJECT : state.availableWorktreesByProject);
|
||||
const knownDirectories = React.useMemo(
|
||||
() => buildKnownSessionDirectories(projects, availableWorktreesByProject, { includeWorktrees: !isVSCode }),
|
||||
[availableWorktreesByProject, isVSCode, projects],
|
||||
);
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||
const bootstrapDemandOwner = `session-list-sync:${React.useId()}`;
|
||||
|
||||
React.useEffect(() => {
|
||||
childStores.setBootstrapDemand(bootstrapDemandOwner, buildSessionBootstrapDemands({
|
||||
knownDirectories,
|
||||
activeProjectDirectory: normalizePath(projects.find((project) => project.id === activeProjectId)?.path ?? null),
|
||||
activeProjectId,
|
||||
collapsedProjects: new Set(),
|
||||
collapsedGroups: new Set(),
|
||||
currentDirectory,
|
||||
currentSessionDirectory,
|
||||
}));
|
||||
return () => childStores.clearBootstrapDemand(bootstrapDemandOwner);
|
||||
}, [activeProjectId, bootstrapDemandOwner, childStores, currentDirectory, currentSessionDirectory, knownDirectories, projects]);
|
||||
|
||||
const knownProjectSessionDirectoriesRef = React.useRef<Set<string> | null>(null);
|
||||
React.useEffect(() => {
|
||||
const directories = new Set(knownDirectories);
|
||||
const previous = knownProjectSessionDirectoriesRef.current;
|
||||
knownProjectSessionDirectoriesRef.current = directories;
|
||||
const added = previous ? [...directories].filter((directory) => !previous.has(directory)) : isVSCode ? [...directories] : [];
|
||||
if (added.length) void refreshGlobalSessionsForDirectories(added, getAllSyncSessions());
|
||||
}, [isVSCode, knownDirectories]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshAll = false;
|
||||
const directories = new Set<string>();
|
||||
const unsubscribe = subscribeOpenchamberEvents((event) => {
|
||||
if (event.type === 'scheduled-task-ran') refreshAll = true;
|
||||
else if (event.type === 'session-created') directories.add(event.directory);
|
||||
else return;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
if (refreshAll) {
|
||||
refreshAll = false;
|
||||
directories.clear();
|
||||
void refreshGlobalSessions(getAllSyncSessions());
|
||||
return;
|
||||
}
|
||||
const requested = [...directories];
|
||||
directories.clear();
|
||||
if (requested.length) void refreshGlobalSessionsForDirectories(requested, getAllSyncSessions());
|
||||
}, 500);
|
||||
});
|
||||
return () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleanupSessions = React.useMemo(
|
||||
() => [...globalActiveSessions, ...archivedSessions],
|
||||
[archivedSessions, globalActiveSessions],
|
||||
);
|
||||
useAuthoritativeSessionCleanup({
|
||||
enabled: true,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
sessions: cleanupSessions,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionPrefetch } from './useSessionPrefetch';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
const session = (id: string): Session => ({
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
title: id,
|
||||
version: '1',
|
||||
directory: '/workspace',
|
||||
time: { created: 1, updated: 1 },
|
||||
});
|
||||
|
||||
describe('session prefetch demand', () => {
|
||||
test('deduplicates the same nearby session from project and Recent projections', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const current = session('current');
|
||||
const nearby = session('nearby');
|
||||
const calls: string[] = [];
|
||||
const Harness = () => {
|
||||
useSessionPrefetch({
|
||||
enabled: true,
|
||||
currentSessionId: current.id,
|
||||
sortedSessions: [current, nearby],
|
||||
recentSessions: [current, nearby],
|
||||
prefetchSession: async ({ sessionID }) => { calls.push(sessionID); },
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 850)); });
|
||||
expect(calls).toEqual(['nearby']);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
+15
-15
@@ -14,7 +14,7 @@ type Args = {
|
||||
currentSessionId: string | null;
|
||||
sortedSessions: Session[];
|
||||
recentSessions?: Session[];
|
||||
prefetchSession: (sessionId: string, directory: string) => Promise<unknown>;
|
||||
prefetchSession: (target: { directory: string; sessionID: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
type PrefetchRequest = {
|
||||
@@ -23,9 +23,13 @@ type PrefetchRequest = {
|
||||
generation: number;
|
||||
};
|
||||
|
||||
const getPrefetchRequestKey = (request: Pick<PrefetchRequest, 'directory' | 'sessionId'>): string => (
|
||||
`${request.directory}\n${request.sessionId}`
|
||||
);
|
||||
|
||||
const sessionDirectory = (session: Session | null | undefined): string | null => {
|
||||
const directory = (session as (Session & { directory?: string | null }) | null | undefined)?.directory;
|
||||
return typeof directory === 'string' && directory.trim() ? directory : null;
|
||||
const directory = session?.directory?.trim();
|
||||
return directory || null;
|
||||
};
|
||||
|
||||
export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
||||
@@ -35,10 +39,6 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
|
||||
const generationRef = React.useRef(0);
|
||||
const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
const requestKey = React.useCallback((request: Pick<PrefetchRequest, 'directory' | 'sessionId'>) => (
|
||||
`${request.directory}\n${request.sessionId}`
|
||||
), []);
|
||||
|
||||
const clearPendingPrefetches = React.useCallback(() => {
|
||||
generationRef.current += 1;
|
||||
sessionPrefetchQueueRef.current = [];
|
||||
@@ -47,7 +47,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
|
||||
}, []);
|
||||
|
||||
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
||||
if (!enabled || prefetchDisabled || typeof window === 'undefined') {
|
||||
if (!enabled || prefetchDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,25 +68,25 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = requestKey(request);
|
||||
const key = getPrefetchRequestKey(request);
|
||||
sessionPrefetchInFlightRef.current.add(key);
|
||||
void prefetchSession(request.sessionId, request.directory)
|
||||
void prefetchSession({ directory: request.directory, sessionID: request.sessionId })
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
sessionPrefetchInFlightRef.current.delete(key);
|
||||
pumpSessionPrefetchQueue();
|
||||
});
|
||||
}
|
||||
}, [enabled, prefetchDisabled, prefetchSession, requestKey]);
|
||||
}, [enabled, prefetchDisabled, prefetchSession]);
|
||||
|
||||
const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
|
||||
const sessionId = session?.id;
|
||||
const directory = sessionDirectory(session);
|
||||
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId) {
|
||||
return;
|
||||
}
|
||||
const request = { sessionId, directory, generation: generationRef.current };
|
||||
const key = requestKey(request);
|
||||
const key = getPrefetchRequestKey(request);
|
||||
|
||||
// Already renderable in sync
|
||||
if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) {
|
||||
@@ -97,7 +97,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) {
|
||||
if (sessionPrefetchQueueRef.current.some((candidate) => getPrefetchRequestKey(candidate) === key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes
|
||||
pumpSessionPrefetchQueue();
|
||||
}, SESSION_PREFETCH_HOVER_DELAY_MS);
|
||||
sessionPrefetchTimersRef.current.set(key, timer);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue]);
|
||||
|
||||
React.useEffect(() => {
|
||||
clearPendingPrefetches();
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type FolderCallbacks = {
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
type RowPropsCapture = Pick<SessionGroupSectionProps,
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'copiedSessionId'
|
||||
| 'setCopiedSessionId'
|
||||
>;
|
||||
|
||||
let folderCallbacks: FolderCallbacks | null = null;
|
||||
let rowPropsCapture: RowPropsCapture | null = null;
|
||||
|
||||
mock.module('../../SessionFolderItem', () => ({
|
||||
SessionFolderItem: (props: FolderCallbacks) => {
|
||||
folderCallbacks = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../folders/sessionFolderDnd', () => ({
|
||||
DroppableFolderWrapper: ({ children }: { children: (ref: () => void, isOver: boolean) => React.ReactNode }) => <>{children(() => undefined, false)}</>,
|
||||
SessionFolderDndScope: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
mock.module('@/sync/sync-context', () => ({
|
||||
setActiveSession: () => undefined,
|
||||
useChildStoreManager: () => ({
|
||||
subscribeBootstrap: () => () => undefined,
|
||||
getBootstrapState: () => null,
|
||||
getBootstrapFailure: () => undefined,
|
||||
requestBootstrap: () => undefined,
|
||||
}),
|
||||
useDirectoryStore: () => null,
|
||||
useGlobalSessionStatus: () => null,
|
||||
useSessionPermissions: () => null,
|
||||
useSessionQuestionCount: () => 0,
|
||||
useSyncSDK: () => null,
|
||||
useSyncDirectory: () => null,
|
||||
buildSessionMessageRecordsSnapshot: () => [],
|
||||
}));
|
||||
|
||||
mock.module('../sessions/collapsedActivityIndicator', () => ({
|
||||
CollapsedSessionActivityIndicator: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../sessions/collapsedActivityState', () => ({
|
||||
useCollapsedSessionActivityState: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../sessions/SessionTreeItem', () => ({
|
||||
SessionTreeItem: (props: RowPropsCapture) => {
|
||||
rowPropsCapture = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const { SessionGroupSection } = await import('./SessionGroupSection');
|
||||
|
||||
const folder: SessionFolder = {
|
||||
id: 'folder-a',
|
||||
name: 'Initial folder',
|
||||
parentId: null,
|
||||
sessionIds: [],
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const group: SessionGroupSectionProps['group'] = {
|
||||
id: 'main',
|
||||
label: 'Main',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: '/workspace',
|
||||
folderScopeKey: '/workspace',
|
||||
sessions: [],
|
||||
};
|
||||
|
||||
const groupWithSession: SessionGroupSectionProps['group'] = {
|
||||
...group,
|
||||
// SAFETY: SessionGroupSection only reads the fixture session's id in this test.
|
||||
sessions: [{ session: { id: 'session-a' } as Session, children: [], worktree: null }],
|
||||
};
|
||||
|
||||
const createProps = (): SessionGroupSectionProps => ({
|
||||
group,
|
||||
groupKey: 'project:main',
|
||||
projectId: 'project',
|
||||
hideGroupLabel: true,
|
||||
hasSessionSearchQuery: false,
|
||||
normalizedSessionSearchQuery: '',
|
||||
groupSearchDataByGroup: new WeakMap(),
|
||||
collapsedGroups: new Set(),
|
||||
hideDirectoryControls: false,
|
||||
showMoreGroupSessions: () => undefined,
|
||||
resetGroupSessionLimit: () => undefined,
|
||||
mobileVariant: false,
|
||||
alwaysShowActions: false,
|
||||
activeProjectId: null,
|
||||
setActiveProjectIdOnly: () => undefined,
|
||||
setSessionSwitcherOpen: () => undefined,
|
||||
openNewSessionDraft: () => undefined,
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderIndex: new Map(),
|
||||
notifyOnSubtasks: false,
|
||||
expandedParents: new Set(),
|
||||
editingId: null,
|
||||
editTitle: '',
|
||||
copiedSessionId: null,
|
||||
openSidebarMenuKey: null,
|
||||
setEditingId: () => undefined,
|
||||
setEditTitle: () => undefined,
|
||||
toggleParent: () => undefined,
|
||||
setOpenSidebarMenuKey: () => undefined,
|
||||
startFolderRename: () => undefined,
|
||||
allowReselect: false,
|
||||
isSessionSearchOpen: false,
|
||||
sessionSearchQuery: '',
|
||||
setSessionSearchQuery: () => undefined,
|
||||
setIsSessionSearchOpen: () => undefined,
|
||||
deleteSessionConfirm: null,
|
||||
setDeleteSessionConfirm: () => undefined,
|
||||
setCopiedSessionId: () => undefined,
|
||||
startSessionWorktreeMenuLoad: () => ({
|
||||
cachedTargets: [],
|
||||
refreshTargets: Promise.resolve([]),
|
||||
}),
|
||||
onToggleCollapsedGroup: () => undefined,
|
||||
folderRename: null,
|
||||
setFolderRenameDraft: () => undefined,
|
||||
clearFolderRename: () => undefined,
|
||||
});
|
||||
|
||||
describe('SessionGroupSection public behavior', () => {
|
||||
test('routes rendered folder rename and delete actions to the owning folder store', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const originalFolders = useSessionFoldersStore.getState();
|
||||
const originalUi = useUIStore.getState();
|
||||
useSessionFoldersStore.setState({ foldersMap: { '/workspace': [folder] } });
|
||||
useUIStore.setState({ showDeletionDialog: false });
|
||||
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><SessionGroupSection {...createProps()} /></I18nProvider>));
|
||||
expect(folderCallbacks).not.toBeNull();
|
||||
|
||||
await act(async () => folderCallbacks?.onRename('Renamed folder'));
|
||||
expect(useSessionFoldersStore.getState().foldersMap['/workspace']?.[0]?.name).toBe('Renamed folder');
|
||||
|
||||
await act(async () => folderCallbacks?.onDelete());
|
||||
expect(useSessionFoldersStore.getState().foldersMap['/workspace']).toEqual([]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
useSessionFoldersStore.setState(originalFolders, true);
|
||||
useUIStore.setState(originalUi, true);
|
||||
folderCallbacks = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('propagates confirmation, search/navigation, and copy ownership changes to rendered rows', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const firstSelected = () => undefined;
|
||||
const nextSelected = () => undefined;
|
||||
const firstCopied = () => undefined;
|
||||
const nextCopied = () => undefined;
|
||||
const initialProps = createProps();
|
||||
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} onSessionSelected={firstSelected} setCopiedSessionId={firstCopied} /></I18nProvider>));
|
||||
expect(rowPropsCapture?.onSessionSelected).toBe(firstSelected);
|
||||
expect(rowPropsCapture?.sessionSearchQuery).toBe('');
|
||||
expect(rowPropsCapture?.deleteSessionConfirm).toBeNull();
|
||||
expect(rowPropsCapture?.copiedSessionId).toBeNull();
|
||||
expect(rowPropsCapture?.setCopiedSessionId).toBe(firstCopied);
|
||||
|
||||
// SAFETY: the confirmation is only forwarded by identity to the row mock.
|
||||
const confirmation = { session: { id: 'session-a' } as Session, descendantCount: 0, descendantIds: [], archivedBucket: false };
|
||||
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} allowReselect onSessionSelected={nextSelected} isSessionSearchOpen sessionSearchQuery="search" deleteSessionConfirm={confirmation} copiedSessionId="session-a" setCopiedSessionId={nextCopied} /></I18nProvider>));
|
||||
expect(rowPropsCapture?.allowReselect).toBe(true);
|
||||
expect(rowPropsCapture?.onSessionSelected).toBe(nextSelected);
|
||||
expect(rowPropsCapture?.isSessionSearchOpen).toBe(true);
|
||||
expect(rowPropsCapture?.sessionSearchQuery).toBe('search');
|
||||
expect(rowPropsCapture?.deleteSessionConfirm).toBe(confirmation);
|
||||
expect(rowPropsCapture?.copiedSessionId).toBe('session-a');
|
||||
expect(rowPropsCapture?.setCopiedSessionId).toBe(nextCopied);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
rowPropsCapture = null;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { normalizeFolderRoots, selectFolderIdsForProjection } from '../sessions/sessionNodeItemUtils';
|
||||
|
||||
const folder = (id: string, parentId: string | null = null, sessionIds: string[] = []): SessionFolder => ({
|
||||
id,
|
||||
name: id,
|
||||
parentId,
|
||||
sessionIds,
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
describe('normalizeFolderRoots', () => {
|
||||
test('returns cycle and orphan folders as deterministic fallback roots without duplication', () => {
|
||||
const folders = [
|
||||
folder('cycle-a', 'cycle-b', ['session-a']),
|
||||
folder('cycle-b', 'cycle-a'),
|
||||
folder('orphan', 'missing-parent'),
|
||||
folder('root'),
|
||||
];
|
||||
|
||||
expect(normalizeFolderRoots(folders).map((entry) => entry.id))
|
||||
.toEqual(['orphan', 'root', 'cycle-a']);
|
||||
});
|
||||
|
||||
test('keeps normal nested folder root order unchanged', () => {
|
||||
const folders = [folder('root-a'), folder('child-a', 'root-a'), folder('root-b')];
|
||||
|
||||
expect(normalizeFolderRoots(folders).map((entry) => entry.id)).toEqual(['root-a', 'root-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectFolderIdsForProjection', () => {
|
||||
const malformedFolders = [
|
||||
{ id: 'cycle-a', name: 'cycle-a', parentId: 'cycle-b', nodeCount: 0 },
|
||||
{ id: 'cycle-b', name: 'cycle-b', parentId: 'cycle-a', nodeCount: 1 },
|
||||
{ id: 'orphan', name: 'orphan', parentId: 'missing-parent', nodeCount: 0 },
|
||||
];
|
||||
|
||||
test('keeps malformed empty and nonempty folders in every projection mode', () => {
|
||||
for (const archivedBucket of [false, true]) {
|
||||
for (const searchQuery of ['', 'does-not-match']) {
|
||||
expect([...selectFolderIdsForProjection(malformedFolders, { archivedBucket, searchQuery })])
|
||||
.toEqual(['cycle-a', 'cycle-b', 'orphan']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps normal archived/search nesting semantics', () => {
|
||||
const folders = [
|
||||
{ id: 'root', name: 'root', parentId: null, nodeCount: 0 },
|
||||
{ id: 'child', name: 'matching-child', parentId: 'root', nodeCount: 1 },
|
||||
];
|
||||
|
||||
expect([...selectFolderIdsForProjection(folders, { archivedBucket: true, searchQuery: 'matching' })])
|
||||
.toEqual(['root', 'child']);
|
||||
});
|
||||
|
||||
test('keeps a fuzzy folder match and its ancestor', () => {
|
||||
const folders = [
|
||||
{ id: 'root', name: 'Root', parentId: null, nodeCount: 0 },
|
||||
{ id: 'child', name: 'Release Notes', parentId: 'root', nodeCount: 0 },
|
||||
];
|
||||
|
||||
expect([...selectFolderIdsForProjection(folders, { archivedBucket: false, searchQuery: 'release-notes' })])
|
||||
.toEqual(['root', 'child']);
|
||||
});
|
||||
});
|
||||
+367
-296
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections } from './sessionProjectRender';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
const makeGroup = (id: string, overrides: Partial<SessionGroup> = {}): SessionGroup => ({
|
||||
id,
|
||||
label: id,
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: id === 'main',
|
||||
worktree: null,
|
||||
directory: '/workspace',
|
||||
sessions: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('buildGroupRenderDescriptors', () => {
|
||||
test('renders the main group and archived bucket for the main workspace', () => {
|
||||
const section = {
|
||||
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||
groups: [makeGroup('main'), makeGroup('archived', { isArchivedBucket: true })],
|
||||
};
|
||||
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: true })).toEqual([
|
||||
{
|
||||
group: section.groups[0],
|
||||
groupKey: 'project-a:main',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: true,
|
||||
},
|
||||
{
|
||||
group: section.groups[1],
|
||||
groupKey: 'project-a:archived',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('renders the primary group without a label and nested groups with labels', () => {
|
||||
const section = {
|
||||
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||
groups: [makeGroup('main'), makeGroup('feature')],
|
||||
};
|
||||
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false })).toEqual([
|
||||
{
|
||||
group: section.groups[0],
|
||||
groupKey: 'project-a:main',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: true,
|
||||
},
|
||||
{
|
||||
group: section.groups[1],
|
||||
groupKey: 'project-a:feature',
|
||||
projectId: 'project-a',
|
||||
hideGroupLabel: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps labels when a flat section has no main group', () => {
|
||||
const section = {
|
||||
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||
groups: [makeGroup('feature', { isMain: false }), makeGroup('other', { isMain: false })],
|
||||
};
|
||||
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-project scroller projection', () => {
|
||||
test('renders only the selected project from persisted display state', () => {
|
||||
const previous = useSessionDisplayStore.getState();
|
||||
const sections = [
|
||||
{ project: { id: 'project-a', normalizedPath: '/workspace/a' }, groups: [] },
|
||||
{ project: { id: 'project-b', normalizedPath: '/workspace/b' }, groups: [] },
|
||||
];
|
||||
|
||||
try {
|
||||
useSessionDisplayStore.setState({ projectDisplayMode: 'single', singleProjectId: 'project-b' });
|
||||
const state = useSessionDisplayStore.getState();
|
||||
|
||||
expect(selectRenderedProjectSections(sections, state.projectDisplayMode === 'single', state.singleProjectId)
|
||||
.map((section) => section.project.id)).toEqual(['project-b']);
|
||||
} finally {
|
||||
useSessionDisplayStore.setState(previous, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
+202
-185
@@ -10,28 +10,120 @@ import {
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
|
||||
import type { SessionGroup } from './types';
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { formatProjectLabel } from './utils';
|
||||
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender';
|
||||
import { formatProjectLabel } from '../utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
type ProjectSection = {
|
||||
project: {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
groups: SessionGroup[];
|
||||
type SessionProjectScrollerState = Pick<SessionGroupSectionProps,
|
||||
| 'editingId'
|
||||
| 'openSidebarMenuKey'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
> & {
|
||||
visibleSessionCountByGroup: Map<string, number>;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerGroupProps = Pick<SessionGroupSectionProps,
|
||||
| 'hasSessionSearchQuery'
|
||||
| 'normalizedSessionSearchQuery'
|
||||
| 'groupSearchDataByGroup'
|
||||
| 'collapsedGroups'
|
||||
| 'hideDirectoryControls'
|
||||
| 'mobileVariant'
|
||||
| 'alwaysShowActions'
|
||||
| 'activeProjectId'
|
||||
| 'notifyOnSubtasks'
|
||||
| 'expandedParents'
|
||||
| 'editTitle'
|
||||
| 'copiedSessionId'
|
||||
| 'folderRename'
|
||||
| 'setFolderRenameDraft'
|
||||
| 'clearFolderRename'
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'setSessionSearchQuery'
|
||||
| 'setIsSessionSearchOpen'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
> & {
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerGroupActions = Pick<SessionGroupSectionProps,
|
||||
| 'showMoreGroupSessions'
|
||||
| 'resetGroupSessionLimit'
|
||||
| 'setActiveProjectIdOnly'
|
||||
| 'setSessionSwitcherOpen'
|
||||
| 'openNewSessionDraft'
|
||||
| 'onToggleCollapsedGroup'
|
||||
>;
|
||||
|
||||
type SessionProjectScrollerModel = {
|
||||
topContent?: React.ReactNode;
|
||||
hasSharedSessions?: boolean;
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
singleProjectMode: boolean;
|
||||
singleProjectId: string | null;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
stuckProjectHeaders: Set<string>;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
state: SessionProjectScrollerState;
|
||||
groupProps: SessionProjectScrollerGroupProps;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerView = {
|
||||
homeDirectory: string | null;
|
||||
collapsedProjects: Set<string>;
|
||||
showOnlyMainWorkspace: boolean;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
hideDirectoryControls: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stickyZoneHeaders: boolean;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
projectSortOrder: ProjectSortOrder;
|
||||
};
|
||||
|
||||
type SessionProjectScrollerActions = {
|
||||
group: SessionProjectScrollerGroupActions;
|
||||
toggleProject: (id: string) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openWorktreesPage: (id: string) => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
setSingleProjectId: (id: string) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
model: SessionProjectScrollerModel;
|
||||
view: SessionProjectScrollerView;
|
||||
actions: SessionProjectScrollerActions;
|
||||
};
|
||||
|
||||
const TOP_FADE_MAX_SIZE = 48;
|
||||
@@ -46,59 +138,12 @@ const getProjectLabel = (project: ProjectSection['project'], homeDirectory: stri
|
||||
)
|
||||
);
|
||||
|
||||
type Props = {
|
||||
topContent?: React.ReactNode;
|
||||
sharedSessionsOnly?: boolean;
|
||||
hasSharedSessions?: boolean;
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
showOnlyMainWorkspace: boolean;
|
||||
hasSessionSearchQuery: boolean;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
renderGroupSessions: (
|
||||
group: SessionGroup,
|
||||
groupKey: string,
|
||||
projectId?: string | null,
|
||||
hideGroupLabel?: boolean,
|
||||
dragHandleProps?: SortableDragHandleProps | null,
|
||||
compactBodyPadding?: boolean,
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>,
|
||||
) => React.ReactNode;
|
||||
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
homeDirectory: string | null;
|
||||
collapsedProjects: Set<string>;
|
||||
hideDirectoryControls: boolean;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stickyZoneHeaders: boolean;
|
||||
stuckProjectHeaders: Set<string>;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
toggleProject: (id: string) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openWorktreesPage: (id: string) => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
projectSortOrder: ProjectSortOrder;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
isInlineEditing: boolean;
|
||||
};
|
||||
|
||||
function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_projects_list.render');
|
||||
const { t } = useI18n();
|
||||
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders;
|
||||
const { model, view, actions } = props;
|
||||
const isInlineEditing = model.state.editingId !== null;
|
||||
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders && !model.singleProjectMode;
|
||||
const projectSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -107,30 +152,6 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
// Memoize getOrderedGroups per project so downstream consumers see a stable
|
||||
// array reference while inputs are unchanged (avoids O(P) fresh arrays per
|
||||
// list render invalidating the memoized group subtrees).
|
||||
const orderedGroupsCacheRef = React.useRef<Map<string, { groups: SessionGroup[]; ordered: SessionGroup[] }>>(new Map());
|
||||
const orderedGroupsCacheGetOrderedGroupsRef = React.useRef<typeof props.getOrderedGroups>(props.getOrderedGroups);
|
||||
if (orderedGroupsCacheGetOrderedGroupsRef.current !== props.getOrderedGroups) {
|
||||
orderedGroupsCacheGetOrderedGroupsRef.current = props.getOrderedGroups;
|
||||
orderedGroupsCacheRef.current.clear();
|
||||
}
|
||||
const cachedGetOrderedGroups = (projectId: string, groups: SessionGroup[]): SessionGroup[] => {
|
||||
const cache = orderedGroupsCacheRef.current;
|
||||
const hit = cache.get(projectId);
|
||||
if (hit && hit.groups === groups) {
|
||||
return hit.ordered;
|
||||
}
|
||||
const ordered = props.getOrderedGroups(projectId, groups);
|
||||
cache.set(projectId, { groups, ordered });
|
||||
if (cache.size > 256) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey !== undefined) cache.delete(firstKey);
|
||||
}
|
||||
return ordered;
|
||||
};
|
||||
|
||||
// Threaded into SessionGroupSection so the archived-bucket virtualizer
|
||||
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
||||
// walk) and skip the cost of a style recalc on every render.
|
||||
@@ -138,7 +159,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
// Keep per-scroll measurements out of React state so the interaction guard
|
||||
// can read the current fade boundary without rerendering the sidebar.
|
||||
const topFadeSizeRef = React.useRef(0);
|
||||
// Update the compositor-owned mask on every scroll, but cross the React
|
||||
// Update the viewport-owned fade on every scroll, but cross the React
|
||||
// render boundary only when the sticky identity overlay appears or hides.
|
||||
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
|
||||
const hasTopScroll = scroller.scrollTop > 1;
|
||||
@@ -146,8 +167,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE)
|
||||
: 0;
|
||||
topFadeSizeRef.current = topFadeSize;
|
||||
scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
|
||||
scroller.style.setProperty(
|
||||
const fadeRoot = scroller.closest<HTMLElement>('.oc-sticky-fade-root');
|
||||
fadeRoot?.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
|
||||
fadeRoot?.style.setProperty(
|
||||
'--scroll-shadow-top-clear-size',
|
||||
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
|
||||
);
|
||||
@@ -155,49 +177,55 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
const blockObscuredInteraction = React.useCallback((
|
||||
event: React.MouseEvent<HTMLDivElement> | React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
// SAFETY: React's mouse and pointer events are dispatched from Elements.
|
||||
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return;
|
||||
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
|
||||
if (eventY >= topFadeSizeRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const hasProjectScroller = props.projectSections.length > 0 && props.sectionsForRender.length > 0;
|
||||
const renderedSections = selectRenderedProjectSections(
|
||||
model.sectionsForRender,
|
||||
model.singleProjectMode,
|
||||
model.singleProjectId,
|
||||
);
|
||||
const hasProjectScroller = model.projectSections.length > 0 && renderedSections.length > 0;
|
||||
React.useLayoutEffect(() => {
|
||||
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
|
||||
syncTopFade(scrollContainerRef.current);
|
||||
}
|
||||
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
|
||||
let stuckProject: ProjectSection['project'] | null = null;
|
||||
for (const section of props.projectSections) {
|
||||
if (props.stuckProjectHeaders.has(section.project.id)) {
|
||||
for (const section of model.projectSections) {
|
||||
if (model.stuckProjectHeaders.has(section.project.id)) {
|
||||
stuckProject = section.project;
|
||||
}
|
||||
}
|
||||
// The IntersectionObserver reports the stuck header asynchronously, a frame or
|
||||
// two after the (synchronous) mask has already hidden the real header — which
|
||||
// two after the synchronous fade has already hidden the real header — which
|
||||
// otherwise leaves a one-frame gap where the title blinks out with no crisp
|
||||
// replacement. Seed the overlay with the topmost rendered project so it is
|
||||
// ready in the same frame; the observer then corrects it. When shared sessions
|
||||
// lead the list, the Recent fallback below owns the top instead of a project.
|
||||
const leadingProject =
|
||||
stuckProject ?? (props.hasSharedSessions ? null : props.sectionsForRender[0]?.project ?? null);
|
||||
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, props.homeDirectory) : null;
|
||||
stuckProject ?? (model.hasSharedSessions ? null : renderedSections[0]?.project ?? null);
|
||||
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
|
||||
const projectPickerOptions = React.useMemo(() => model.projectSections.map((section) => ({
|
||||
id: section.project.id,
|
||||
projectLabel: getProjectLabel(section.project, view.homeDirectory),
|
||||
projectDescription: formatPathForDisplay(section.project.normalizedPath, view.homeDirectory),
|
||||
projectIcon: section.project.icon,
|
||||
projectColor: section.project.color,
|
||||
projectIconImage: section.project.iconImage,
|
||||
projectIconBackground: section.project.iconBackground,
|
||||
})), [model.projectSections, view.homeDirectory]);
|
||||
|
||||
if (props.sharedSessionsOnly) {
|
||||
return (
|
||||
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
|
||||
{props.topContent}
|
||||
{!props.hasSharedSessions ? (props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState) : null}
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
if (model.projectSections.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.topContent}{model.emptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
if (props.projectSections.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.topContent}{props.emptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
if (props.sectionsForRender.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.searchEmptyState}</ScrollableOverlay>;
|
||||
if (model.sectionsForRender.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.searchEmptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -208,48 +236,37 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
// rows appear below naturally.
|
||||
<div
|
||||
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
|
||||
// SAFETY: this custom property configures the viewport-owned edge fade.
|
||||
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
||||
onPointerDownCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
ref={scrollContainerRef}
|
||||
useScrollShadow
|
||||
hideTopScrollShadow={!enableStickyFade}
|
||||
scrollShadowSize={96}
|
||||
outerClassName="flex-1 min-h-0"
|
||||
className={cn('oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}
|
||||
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
||||
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
||||
>
|
||||
{props.topContent}
|
||||
{props.showOnlyMainWorkspace ? (
|
||||
<ScrollableOverlay
|
||||
ref={scrollContainerRef}
|
||||
useScrollShadow
|
||||
hideTopScrollShadow={!enableStickyFade}
|
||||
scrollShadowSize={96}
|
||||
outerClassName="flex-1 min-h-0"
|
||||
className="oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]"
|
||||
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
||||
>
|
||||
{model.topContent}
|
||||
{view.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
{(() => {
|
||||
const activeSection = props.sectionsForRender.find((section) => section.project.id === props.activeProjectId) ?? props.sectionsForRender[0];
|
||||
const activeSection = renderedSections.find((section) => section.project.id === model.activeProjectId) ?? renderedSections[0];
|
||||
if (!activeSection) {
|
||||
return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState;
|
||||
return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
|
||||
}
|
||||
const primaryGroup =
|
||||
activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0)
|
||||
?? activeSection.groups.find((candidate) => candidate.sessions.length > 0)
|
||||
?? activeSection.groups.find((candidate) => candidate.isMain)
|
||||
?? activeSection.groups[0];
|
||||
if (!primaryGroup) {
|
||||
const descriptors = buildGroupRenderDescriptors(activeSection, { mainWorkspaceOnly: true });
|
||||
if (!descriptors.length) {
|
||||
return <div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>;
|
||||
}
|
||||
const archivedGroup = activeSection.groups.find((candidate) => candidate.isArchivedBucket);
|
||||
const groupsToRender = [
|
||||
primaryGroup,
|
||||
...(archivedGroup && archivedGroup.id !== primaryGroup.id ? [archivedGroup] : []),
|
||||
];
|
||||
|
||||
return groupsToRender.map((group) => {
|
||||
const groupKey = `${activeSection.project.id}:${group.id}`;
|
||||
const hideGroupLabel = group.id === primaryGroup.id;
|
||||
return descriptors.map(({ group, groupKey, projectId, hideGroupLabel }) => {
|
||||
return (
|
||||
<React.Fragment key={groupKey}>
|
||||
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true, scrollContainerRef)}
|
||||
<SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectId} hideGroupLabel={hideGroupLabel} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} compactBodyPadding scrollContainerRef={scrollContainerRef} />
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
@@ -260,31 +277,31 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
sensors={projectSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
if (isInlineEditing) return;
|
||||
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
||||
if (props.projectSortOrder !== 'manual') return;
|
||||
if (view.projectSortOrder !== 'manual') return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
const oldIndex = model.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = model.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
props.reorderProjects(oldIndex, newIndex);
|
||||
actions.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{props.sectionsForRender.map((section) => {
|
||||
<SortableContext items={renderedSections.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{renderedSections.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = getProjectLabel(project, props.homeDirectory);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
const projectLabel = getProjectLabel(project, view.homeDirectory);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory);
|
||||
const isCollapsed = model.singleProjectMode ? false : view.collapsedProjects.has(projectKey);
|
||||
const isRepo = model.projectRepoStatus.get(projectKey);
|
||||
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
disabled={props.projectSortOrder !== 'manual'}
|
||||
disabled={model.singleProjectMode || view.projectSortOrder !== 'manual'}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
@@ -293,38 +310,38 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
statusIndicator={isCollapsed ? props.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||
onToggle={() => props.toggleProject(projectKey)}
|
||||
isDesktopShell={view.isDesktopShellRuntime}
|
||||
hideDirectoryControls={view.hideDirectoryControls}
|
||||
mobileVariant={view.mobileVariant}
|
||||
alwaysShowActions={view.alwaysShowActions}
|
||||
statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||
openSidebarMenuKey={model.state.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
||||
projectPickerOptions={model.singleProjectMode ? projectPickerOptions : undefined}
|
||||
onProjectSelect={model.singleProjectMode ? actions.setSingleProjectId : undefined}
|
||||
onToggle={() => { if (!model.singleProjectMode) actions.toggleProject(projectKey); }}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewSessionDraft({
|
||||
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||
if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
|
||||
actions.openNewSessionDraft({
|
||||
selectedProjectId: projectKey,
|
||||
directoryOverride: project.normalizedPath,
|
||||
});
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
props.openNewWorktreeDialog();
|
||||
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||
actions.openNewWorktreeDialog();
|
||||
}}
|
||||
onManageWorktrees={() => props.openWorktreesPage(projectKey)}
|
||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
onManageWorktrees={() => actions.openWorktreesPage(projectKey)}
|
||||
onRenameStart={() => actions.openProjectEditDialog(projectKey)}
|
||||
onClose={() => actions.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { model.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
showCreateButtons
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
>
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-0 pt-0.5 pb-0.5">
|
||||
{(() => {
|
||||
const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
|
||||
const orderedGroups = section.groups;
|
||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||
const nestedGroups = rootGroup
|
||||
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
||||
@@ -334,7 +351,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
sensors={groupSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
if (isInlineEditing) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = nestedGroups.findIndex((item) => item.id === active.id);
|
||||
@@ -342,7 +359,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id);
|
||||
const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested;
|
||||
props.setGroupOrderByProject((prev) => {
|
||||
actions.setGroupOrderByProject((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(projectKey, next);
|
||||
return map;
|
||||
@@ -352,13 +369,13 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
{/* Root/flat sessions render directly under the
|
||||
project zone header; worktree and archived
|
||||
groups keep their own slim sortable sub-header. */}
|
||||
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
|
||||
{rootGroup ? <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={rootGroup} groupKey={`${projectKey}:${rootGroup.id}`} projectId={projectKey} hideGroupLabel visibleSessionCount={model.state.visibleSessionCountByGroup.get(`${projectKey}:${rootGroup.id}`)} scrollContainerRef={scrollContainerRef} /> : null}
|
||||
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
||||
{nestedGroups.map((group) => {
|
||||
const groupKey = `${projectKey}:${group.id}`;
|
||||
return (
|
||||
<SortableGroupItem key={group.id} id={group.id} disabled={props.isInlineEditing}>
|
||||
{(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps, undefined, scrollContainerRef)}
|
||||
<SortableGroupItem key={group.id} id={group.id} disabled={isInlineEditing}>
|
||||
{(dragHandleProps) => <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectKey} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} dragHandleProps={dragHandleProps} scrollContainerRef={scrollContainerRef} />}
|
||||
</SortableGroupItem>
|
||||
);
|
||||
})}
|
||||
@@ -376,15 +393,15 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
{enableStickyFade && (leadingProject || props.hasSharedSessions) ? (
|
||||
</ScrollableOverlay>
|
||||
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
|
||||
<div
|
||||
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-1.5 py-1 pl-4 pr-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{leadingProject && leadingProjectLabel ? (
|
||||
<ProjectHeaderIdentity
|
||||
id={leadingProject.id}
|
||||
id={leadingProject.id}
|
||||
projectLabel={leadingProjectLabel}
|
||||
projectIcon={leadingProject.icon}
|
||||
projectColor={leadingProject.color}
|
||||
@@ -405,4 +422,4 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarProjectsList = React.memo(SidebarProjectsListComponent);
|
||||
export const SessionProjectScroller = React.memo(SessionProjectScrollerComponent);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { SessionGroup } from '../types';
|
||||
|
||||
export type ProjectSection = {
|
||||
project: {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
export const selectRenderedProjectSections = (
|
||||
sections: ProjectSection[],
|
||||
singleProjectMode: boolean,
|
||||
singleProjectId: string | null,
|
||||
): ProjectSection[] => singleProjectMode
|
||||
? sections.filter((section) => section.project.id === singleProjectId)
|
||||
: sections;
|
||||
|
||||
type GroupRenderDescriptor = {
|
||||
group: SessionGroup;
|
||||
groupKey: string;
|
||||
projectId: string;
|
||||
hideGroupLabel: boolean;
|
||||
};
|
||||
|
||||
export const buildGroupRenderDescriptors = (
|
||||
section: ProjectSection,
|
||||
options: { mainWorkspaceOnly: boolean },
|
||||
): GroupRenderDescriptor[] => {
|
||||
const primaryGroup = section.groups.find((group) => group.isMain && group.sessions.length > 0)
|
||||
?? section.groups.find((group) => group.sessions.length > 0)
|
||||
?? section.groups.find((group) => group.isMain)
|
||||
?? section.groups[0];
|
||||
if (!primaryGroup) return [];
|
||||
|
||||
const archivedGroup = section.groups.find((group) => group.isArchivedBucket && group.id !== primaryGroup.id);
|
||||
const groups = options.mainWorkspaceOnly
|
||||
? [primaryGroup, ...(archivedGroup ? [archivedGroup] : [])]
|
||||
: [
|
||||
...(section.groups.find((group) => group.isMain) ? [section.groups.find((group) => group.isMain)!] : []),
|
||||
...section.groups.filter((group) => !group.isMain),
|
||||
];
|
||||
|
||||
return groups.map((group) => ({
|
||||
group,
|
||||
groupKey: `${section.project.id}:${group.id}`,
|
||||
projectId: section.project.id,
|
||||
hideGroupLabel: options.mainWorkspaceOnly ? group.id === primaryGroup.id : group.isMain,
|
||||
}));
|
||||
};
|
||||
+37
-9
@@ -35,6 +35,8 @@ type ProjectHeaderIdentityProps = ProjectIdentityProps & {
|
||||
alwaysShowActions?: boolean;
|
||||
};
|
||||
|
||||
type ProjectPickerOption = ProjectIdentityProps & { projectDescription: string };
|
||||
|
||||
export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
|
||||
id,
|
||||
projectLabel,
|
||||
@@ -117,10 +119,12 @@ export interface SortableProjectItemProps extends ProjectIdentityProps {
|
||||
children?: React.ReactNode;
|
||||
showCreateButtons?: boolean;
|
||||
hideHeader?: boolean;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
/** Aggregated activity/attention indicator shown while the project is collapsed. */
|
||||
statusIndicator?: React.ReactNode;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
projectPickerOptions?: ProjectPickerOption[];
|
||||
onProjectSelect?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
@@ -147,9 +151,11 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
children,
|
||||
showCreateButtons = true,
|
||||
hideHeader = false,
|
||||
statusIndicator = null,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
statusIndicator = null,
|
||||
projectPickerOptions,
|
||||
onProjectSelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
@@ -166,6 +172,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
const menuInstanceKey = `project:${id}`;
|
||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
||||
const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
|
||||
|
||||
const handleMenuOpenChange = React.useCallback((open: boolean) => {
|
||||
if (open) setIsContextMenuOpen(false);
|
||||
@@ -273,7 +280,28 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
|
||||
{...attributes}
|
||||
>
|
||||
<Tooltip>
|
||||
{isProjectPicker ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
|
||||
>
|
||||
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} />
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[70vh] min-w-[220px] overflow-y-auto">
|
||||
{projectPickerOptions?.map((option) => (
|
||||
<DropdownMenuItem key={option.id} onClick={() => onProjectSelect?.(option.id)} className="flex items-center justify-between gap-3" title={option.projectDescription}>
|
||||
<ProjectHeaderIdentity {...option} />
|
||||
{option.id === id ? <Icon name="check" className="h-4 w-4 flex-shrink-0 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -305,14 +333,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{projectDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Tooltip>}
|
||||
|
||||
<div className={cn(
|
||||
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
|
||||
showCreateButtons ? 'right-7' : 'right-0.5',
|
||||
)}>
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -368,7 +396,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
|
||||
{showCreateButtons && onNewSession ? (
|
||||
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -412,7 +440,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
const SortableGroupItemBase: React.FC<{
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode | ((dragHandleProps: SortableDragHandleProps) => React.ReactNode);
|
||||
children: (dragHandleProps: SortableDragHandleProps) => React.ReactNode;
|
||||
}> = ({ id, disabled = false, children }) => {
|
||||
const {
|
||||
listeners,
|
||||
@@ -440,7 +468,7 @@ const SortableGroupItemBase: React.FC<{
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{typeof children === 'function' ? children(dragHandleProps) : children}
|
||||
{children(dragHandleProps)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
||||
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||
|
||||
type Args = {
|
||||
ownership: SessionOwnershipIndex;
|
||||
+96
-24
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode } from '../types';
|
||||
import { normalizePath } from '../utils';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
@@ -17,14 +16,77 @@ type Args = {
|
||||
activeSessionByProject: Map<string, string>;
|
||||
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
currentSessionId: string | null;
|
||||
currentSessionOwnerProjectId?: string | null;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
newSessionDraftOpen: boolean;
|
||||
mobileVariant: boolean;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export type MissingProjectSessionSelection =
|
||||
| { kind: 'preserve-current' }
|
||||
| { kind: 'open-draft' }
|
||||
| { kind: 'select-session'; sessionId: string }
|
||||
| { kind: 'none' };
|
||||
|
||||
/**
|
||||
* Resolves the active-project action after its rendered session map does not
|
||||
* contain the current session.
|
||||
*
|
||||
* Authoritative ownership wins. If ownership is still unknown, a session that
|
||||
* already appears under another project's rendered map is treated as foreign,
|
||||
* while a session missing from every rendered map is preserved so stale
|
||||
* worktree metadata can catch up.
|
||||
*/
|
||||
export function resolveMissingProjectSessionSelection<T>({
|
||||
activeProjectId,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
projectMap,
|
||||
metaByProject,
|
||||
rememberedSessionId,
|
||||
fallbackSessionId,
|
||||
}: {
|
||||
activeProjectId: string;
|
||||
currentSessionId: string | null;
|
||||
currentSessionOwnerProjectId?: string | null;
|
||||
projectMap: ReadonlyMap<string, T> | undefined;
|
||||
metaByProject: ReadonlyMap<string, ReadonlyMap<string, T>>;
|
||||
rememberedSessionId: string | undefined;
|
||||
fallbackSessionId: string | null;
|
||||
}): MissingProjectSessionSelection {
|
||||
if (currentSessionId && currentSessionOwnerProjectId === activeProjectId) {
|
||||
return { kind: 'preserve-current' };
|
||||
}
|
||||
|
||||
if (currentSessionOwnerProjectId == null) {
|
||||
const currentSessionBelongsToAnotherProject = Boolean(
|
||||
currentSessionId
|
||||
&& Array.from(metaByProject.entries()).some(
|
||||
([projectId, sessions]) => projectId !== activeProjectId && sessions.has(currentSessionId),
|
||||
),
|
||||
);
|
||||
if (currentSessionId && projectMap && !currentSessionBelongsToAnotherProject) {
|
||||
return { kind: 'preserve-current' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectMap || projectMap.size === 0) {
|
||||
return { kind: 'open-draft' };
|
||||
}
|
||||
|
||||
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
|
||||
? rememberedSessionId
|
||||
: null;
|
||||
const targetSessionId = remembered ?? fallbackSessionId;
|
||||
if (!targetSessionId || targetSessionId === currentSessionId) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
return { kind: 'select-session', sessionId: targetSessionId };
|
||||
}
|
||||
|
||||
export const useProjectSessionSelection = (args: Args): void => {
|
||||
const {
|
||||
projectSections,
|
||||
@@ -32,11 +94,11 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
handleSessionSelect,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
} = args;
|
||||
|
||||
@@ -103,10 +165,10 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
previousActiveProjectRef.current = activeProjectId;
|
||||
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
|
||||
|
||||
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
|
||||
previousActiveProjectRef.current = activeProjectId;
|
||||
setActiveSessionByProject((prev) => {
|
||||
if (prev.get(activeProjectId) === currentSessionId) {
|
||||
return prev;
|
||||
@@ -118,17 +180,28 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Path A' — currentSessionId is set but not in stale projectMap.
|
||||
// Preserve user's explicit selection when the projectMap exists but
|
||||
// is missing the session (worktree data not yet loaded). For
|
||||
// empty projects (projectMap is undefined), fall through to Path B
|
||||
// so a new session draft is opened.
|
||||
if (currentSessionId && projectMap) {
|
||||
const selection = resolveMissingProjectSessionSelection({
|
||||
activeProjectId,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
projectMap,
|
||||
metaByProject: projectSessionMeta.metaByProject,
|
||||
rememberedSessionId: activeSessionByProject.get(activeProjectId),
|
||||
fallbackSessionId: projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null,
|
||||
});
|
||||
|
||||
// Keep the project unprocessed while ownership/maps may still catch up,
|
||||
// so a later owner of another project can still select B.
|
||||
if (selection.kind === 'preserve-current') {
|
||||
if (currentSessionOwnerProjectId === activeProjectId) {
|
||||
previousActiveProjectRef.current = activeProjectId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectMap || projectMap.size === 0) {
|
||||
setActiveMainTab('chat');
|
||||
previousActiveProjectRef.current = activeProjectId;
|
||||
|
||||
if (selection.kind === 'open-draft') {
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
@@ -139,28 +212,22 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const rememberedSessionId = activeSessionByProject.get(activeProjectId);
|
||||
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
|
||||
? rememberedSessionId
|
||||
: null;
|
||||
const fallback = projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null;
|
||||
const targetSessionId = remembered ?? fallback;
|
||||
if (!targetSessionId || targetSessionId === currentSessionId) {
|
||||
if (selection.kind !== 'select-session') {
|
||||
return;
|
||||
}
|
||||
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
|
||||
handleSessionSelect(targetSessionId, targetDirectory);
|
||||
const targetDirectory = projectMap?.get(selection.sessionId)?.directory ?? null;
|
||||
handleSessionSelect(selection.sessionId, targetDirectory);
|
||||
}, [
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
handleSessionSelect,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
projectSections,
|
||||
projectSessionMeta,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
setActiveSessionByProject,
|
||||
]);
|
||||
@@ -182,24 +249,28 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
return next;
|
||||
});
|
||||
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
|
||||
|
||||
};
|
||||
|
||||
type ProjectSessionSelectionEffectProps = Omit<
|
||||
Args,
|
||||
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen'
|
||||
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen' | 'currentSessionOwnerProjectId'
|
||||
> & {
|
||||
initialActiveSessionByProject: Map<string, string>;
|
||||
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
|
||||
};
|
||||
|
||||
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
|
||||
initialActiveSessionByProject,
|
||||
persistActiveSessionByProject,
|
||||
sessionOwnerBySessionId,
|
||||
...args
|
||||
}) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const currentSessionOwnerProjectId = currentSessionId
|
||||
? sessionOwnerBySessionId?.get(currentSessionId)?.projectId ?? null
|
||||
: null;
|
||||
const [activeSessionByProject, setActiveSessionByProject] = React.useState(
|
||||
() => new Map(initialActiveSessionByProject),
|
||||
);
|
||||
@@ -208,6 +279,7 @@ export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffe
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
currentSessionOwnerProjectId,
|
||||
newSessionDraftOpen,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { useSessionActions } from '../sessions/useSessionActions';
|
||||
import { useSessionGrouping } from './useSessionGrouping';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
type FixtureSession = Session & { parentID?: string };
|
||||
const session = (id: string, parentID?: string): Session => {
|
||||
const value: FixtureSession = {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
title: id,
|
||||
version: '1',
|
||||
directory: '/workspace',
|
||||
time: { created: 1, updated: 1 },
|
||||
};
|
||||
if (parentID) value.parentID = parentID;
|
||||
return value;
|
||||
};
|
||||
|
||||
const collectIds = (nodes: SessionNode[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
const visit = (items: SessionNode[]): void => {
|
||||
for (const node of items) {
|
||||
ids.push(node.session.id);
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return ids;
|
||||
};
|
||||
|
||||
describe('useSessionGrouping malformed hierarchy fallbacks', () => {
|
||||
test('renders a deterministic cycle/orphan fallback tree without duplicate sessions', async () => {
|
||||
type GroupingCapture = { buildGroupedSessions?: ReturnType<typeof useSessionGrouping>['buildGroupedSessions'] };
|
||||
const state: GroupingCapture = {};
|
||||
const Harness = () => {
|
||||
state.buildGroupedSessions = useSessionGrouping({
|
||||
homeDirectory: null,
|
||||
worktreeMetadata: new Map(),
|
||||
pinnedSessionIds: new Set(),
|
||||
sessionOrderRanks: new Map(),
|
||||
gitBranches: new Map(),
|
||||
isVSCode: false,
|
||||
}).buildGroupedSessions;
|
||||
return null;
|
||||
};
|
||||
|
||||
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
|
||||
const buildGroupedSessions = state.buildGroupedSessions;
|
||||
if (!buildGroupedSessions) throw new Error('grouping callback was not mounted');
|
||||
|
||||
const groups = buildGroupedSessions(
|
||||
[session('a', 'b'), session('b', 'a'), session('orphan', 'missing')],
|
||||
'/workspace',
|
||||
[],
|
||||
null,
|
||||
false,
|
||||
);
|
||||
const rootGroup = groups.find((group) => group.isMain);
|
||||
const ids = collectIds(rootGroup?.sessions ?? []);
|
||||
|
||||
expect(ids).toEqual(['orphan', 'a', 'b']);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
test('uses the row-local descendant snapshot for archive and hard-delete actions', async () => {
|
||||
type ActionsCapture = { handleDeleteSession?: ReturnType<typeof useSessionActions>['handleDeleteSession'] };
|
||||
const state: ActionsCapture = {};
|
||||
const Harness = () => {
|
||||
state.handleDeleteSession = useSessionActions({
|
||||
mobileVariant: false,
|
||||
allowReselect: false,
|
||||
isSessionSearchOpen: false,
|
||||
sessionSearchQuery: '',
|
||||
setSessionSearchQuery: () => undefined,
|
||||
setIsSessionSearchOpen: () => undefined,
|
||||
descendantIds: ['active-child', 'archived-child'],
|
||||
showDeletionDialog: false,
|
||||
setDeleteSessionConfirm: () => undefined,
|
||||
deleteSessionConfirm: null,
|
||||
setEditingId: () => undefined,
|
||||
setEditTitle: () => undefined,
|
||||
editingId: null,
|
||||
editTitle: '',
|
||||
copiedSessionId: null,
|
||||
setCopiedSessionId: () => undefined,
|
||||
}).handleDeleteSession;
|
||||
return null;
|
||||
};
|
||||
|
||||
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
|
||||
const handleDeleteSession = state.handleDeleteSession;
|
||||
if (!handleDeleteSession) throw new Error('session actions callback was not mounted');
|
||||
|
||||
handleDeleteSession(session('root'));
|
||||
handleDeleteSession(session('root'), { hardDelete: true });
|
||||
});
|
||||
});
|
||||
+29
-12
@@ -1,3 +1,4 @@
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
@@ -8,11 +9,11 @@ import {
|
||||
normalizeForBranchComparison,
|
||||
normalizePath,
|
||||
} from '../utils';
|
||||
import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from '@/sync/session-ordering';
|
||||
import { getSessionLifecycleOrderValue } from '@/sync/session-ordering';
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { getWorktreeFirstSeenAt } from '../worktreeFirstSeen';
|
||||
import { getWorktreeFirstSeenAt } from './worktreeFirstSeen';
|
||||
|
||||
type Args = {
|
||||
homeDirectory: string | null;
|
||||
@@ -44,7 +45,7 @@ export const useSessionGrouping = (args: Args) => {
|
||||
}
|
||||
|
||||
return nodes.flatMap((node) => {
|
||||
const nodeMatches = buildSessionSearchText(node.session).includes(query);
|
||||
const nodeMatches = matchesRankQuery([buildSessionSearchText(node.session)], query);
|
||||
if (nodeMatches) {
|
||||
return [node];
|
||||
}
|
||||
@@ -69,8 +70,9 @@ export const useSessionGrouping = (args: Args) => {
|
||||
projectIsRepo: boolean,
|
||||
) => {
|
||||
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
||||
const sortedProjectSessions = dedupeSessionsById(projectSessions)
|
||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks));
|
||||
// `orderSessionsByLifecycleScopes` owns lifecycle ordering before project
|
||||
// ownership buckets are built. Dedupe retains that root/sibling order.
|
||||
const sortedProjectSessions = dedupeSessionsById(projectSessions);
|
||||
|
||||
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
@@ -85,7 +87,6 @@ export const useSessionGrouping = (args: Args) => {
|
||||
collection.push(session);
|
||||
childrenMap.set(parentID, collection);
|
||||
});
|
||||
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks)));
|
||||
|
||||
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
||||
availableWorktrees.forEach((meta) => {
|
||||
@@ -108,12 +109,19 @@ export const useSessionGrouping = (args: Args) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const claimedSessionIds = new Set<string>();
|
||||
const buildProjectNode = (session: Session): SessionNode => {
|
||||
claimedSessionIds.add(session.id);
|
||||
const children = childrenMap.get(session.id) ?? [];
|
||||
return { session, children: children.map((child) => buildProjectNode(child)), worktree: getSessionWorktree(session) };
|
||||
const childNodes: SessionNode[] = [];
|
||||
for (const child of children) {
|
||||
if (claimedSessionIds.has(child.id)) continue;
|
||||
childNodes.push(buildProjectNode(child));
|
||||
}
|
||||
return { session, children: childNodes, worktree: getSessionWorktree(session) };
|
||||
};
|
||||
|
||||
const roots = sortedProjectSessions.filter((session) => {
|
||||
const rootCandidates = sortedProjectSessions.filter((session) => {
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) return true;
|
||||
const parentSession = sessionMap.get(parentID);
|
||||
@@ -121,6 +129,16 @@ export const useSessionGrouping = (args: Args) => {
|
||||
return isArchivedSession(parentSession) !== isArchivedSession(session);
|
||||
});
|
||||
|
||||
// A malformed cycle has no structural root. Start with normal roots,
|
||||
// then expose each still-unclaimed component from its first input row.
|
||||
const roots: SessionNode[] = [];
|
||||
const addRoot = (session: Session): void => {
|
||||
if (claimedSessionIds.has(session.id)) return;
|
||||
roots.push(buildProjectNode(session));
|
||||
};
|
||||
rootCandidates.forEach(addRoot);
|
||||
sortedProjectSessions.forEach(addRoot);
|
||||
|
||||
const groupedNodes = new Map<string, SessionNode[]>();
|
||||
const archivedKey = '__archived__';
|
||||
|
||||
@@ -139,9 +157,8 @@ export const useSessionGrouping = (args: Args) => {
|
||||
return archivedKey;
|
||||
};
|
||||
|
||||
roots.forEach((session) => {
|
||||
const node = buildProjectNode(session);
|
||||
const groupKey = getGroupKey(session);
|
||||
roots.forEach((node) => {
|
||||
const groupKey = getGroupKey(node.session);
|
||||
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
|
||||
groupedNodes.get(groupKey)?.push(node);
|
||||
});
|
||||
@@ -257,7 +274,7 @@ export const useSessionGrouping = (args: Args) => {
|
||||
|
||||
return groups;
|
||||
},
|
||||
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||
[args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||
);
|
||||
|
||||
return {
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { useSessionProjectViewState } from './useSessionProjectViewState';
|
||||
|
||||
class ElementStub implements Partial<Element> {
|
||||
nodeType = 1;
|
||||
}
|
||||
type DocumentStub = {
|
||||
nodeType: number;
|
||||
defaultView: typeof globalThis;
|
||||
activeElement: null;
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
documentElement?: Element;
|
||||
body?: Element;
|
||||
};
|
||||
type GlobalValue = typeof globalThis | typeof ElementStub | DocumentStub | boolean;
|
||||
type HookCapture = {
|
||||
state?: ReturnType<typeof useSessionProjectViewState>['state'];
|
||||
actions?: ReturnType<typeof useSessionProjectViewState>['actions'];
|
||||
renderCount: number;
|
||||
};
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: GlobalValue) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
const documentStub: DocumentStub = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
// SAFETY: React's test renderer only inspects this fixture's DOM identity fields and listeners.
|
||||
const container = Object.create(ElementStub.prototype) as Element;
|
||||
Object.assign(container, {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument: documentStub,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
});
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
return {
|
||||
container,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('useSessionProjectViewState', () => {
|
||||
beforeEach(() => {
|
||||
const storage = getDeferredSafeStorage();
|
||||
storage.removeItem('oc.sessions.projectCollapse');
|
||||
storage.removeItem('oc.sessions.groupCollapse');
|
||||
storage.removeItem('oc.sessions.groupOrder');
|
||||
});
|
||||
|
||||
test('keeps stable state/actions and ignores selection-store updates', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const capture: HookCapture = { renderCount: 0 };
|
||||
const projects = [{ id: 'project-a' }, { id: 'project-b' }];
|
||||
const Harness = () => {
|
||||
capture.renderCount += 1;
|
||||
const viewState = useSessionProjectViewState({ isVSCode: true, projects });
|
||||
capture.state = viewState.state;
|
||||
capture.actions = viewState.actions;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
const initialState = capture.state;
|
||||
const initialActions = capture.actions;
|
||||
const initialRenderCount = capture.renderCount;
|
||||
if (!initialState || !initialActions) throw new Error('hook did not mount');
|
||||
|
||||
await act(async () => {
|
||||
useSessionUIStore.setState({ currentSessionId: 'selection-only' });
|
||||
});
|
||||
expect(capture.renderCount).toBe(initialRenderCount);
|
||||
expect(capture.state).toBe(initialState);
|
||||
expect(capture.actions).toBe(initialActions);
|
||||
|
||||
await act(async () => initialActions.toggleProject('project-a'));
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||
await act(async () => initialActions.collapseAllProjects());
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a', 'project-b']));
|
||||
await act(async () => initialActions.expandAllProjects());
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set());
|
||||
|
||||
await act(async () => initialActions.toggleGroup('project-a:group-a'));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||
|
||||
await act(async () => {
|
||||
initialActions.setGroupOrderByProject((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set('project-a', ['group-b', 'group-a']);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
const group = (id: string): SessionGroup => ({
|
||||
id,
|
||||
label: id,
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: false,
|
||||
worktree: null,
|
||||
directory: null,
|
||||
sessions: [],
|
||||
});
|
||||
expect(capture.actions?.getOrderedGroups('project-a', [group('group-a'), group('group-b')])
|
||||
.map((item) => item.id)).toEqual(['group-b', 'group-a']);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const storage = getDeferredSafeStorage();
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.projectCollapse') ?? 'null')).toEqual([]);
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({
|
||||
'project-a': ['group-b', 'group-a'],
|
||||
});
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves malformed group storage until explicit user mutation', async () => {
|
||||
const storage = getDeferredSafeStorage();
|
||||
const malformedCollapse = '{malformed-collapse';
|
||||
const malformedOrder = JSON.stringify({ 'project-a': ['group-a', 2] });
|
||||
storage.setItem('oc.sessions.groupCollapse', malformedCollapse);
|
||||
storage.setItem('oc.sessions.groupOrder', malformedOrder);
|
||||
const dom = installMinimalDom();
|
||||
const root = createRoot(dom.container);
|
||||
const capture: HookCapture = { renderCount: 0 };
|
||||
const Harness = () => {
|
||||
capture.renderCount += 1;
|
||||
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
|
||||
capture.state = value.state;
|
||||
capture.actions = value.actions;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set());
|
||||
expect(capture.state?.groupOrderByProject).toEqual(new Map());
|
||||
expect(storage.getItem('oc.sessions.groupCollapse')).toBe(malformedCollapse);
|
||||
expect(storage.getItem('oc.sessions.groupOrder')).toBe(malformedOrder);
|
||||
|
||||
await act(async () => capture.actions!.toggleGroup('project-a:group-a'));
|
||||
await act(async () => capture.actions!.setGroupOrderByProject(new Map([['project-a', ['group-a']]])));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
|
||||
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({ 'project-a': ['group-a'] });
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('retains persisted project/group state while hidden and across a full remount', async () => {
|
||||
const storage = getDeferredSafeStorage();
|
||||
storage.setItem('oc.sessions.projectCollapse', JSON.stringify(['project-a']));
|
||||
storage.setItem('oc.sessions.groupCollapse', JSON.stringify(['project-a:group-a']));
|
||||
storage.setItem('oc.sessions.groupOrder', JSON.stringify({ 'project-a': ['group-b', 'group-a'] }));
|
||||
const dom = installMinimalDom();
|
||||
const root = createRoot(dom.container);
|
||||
const capture: HookCapture = { renderCount: 0 };
|
||||
const Harness = ({ hidden }: { hidden: boolean }) => {
|
||||
void hidden;
|
||||
capture.renderCount += 1;
|
||||
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
|
||||
capture.state = value.state;
|
||||
capture.actions = value.actions;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: true })));
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
|
||||
|
||||
await act(async () => root.render(null));
|
||||
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { z } from 'zod';
|
||||
import { useGroupOrdering } from './useGroupOrdering';
|
||||
|
||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||
|
||||
type Project = { id: string };
|
||||
|
||||
type SessionProjectViewStateArgs = {
|
||||
isVSCode: boolean;
|
||||
projects: readonly Project[];
|
||||
};
|
||||
|
||||
const parseStringSet = (raw: string | null): Set<string> => {
|
||||
if (!raw) return new Set();
|
||||
try {
|
||||
const parsed = z.array(z.string()).safeParse(JSON.parse(raw));
|
||||
return new Set(parsed.success ? parsed.data : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const parseGroupOrder = (raw: string | null): Map<string, string[]> => {
|
||||
if (!raw) return new Map();
|
||||
try {
|
||||
const parsed = z.record(z.string(), z.array(z.string())).safeParse(JSON.parse(raw));
|
||||
if (!parsed.success) return new Map();
|
||||
const next = new Map<string, string[]>();
|
||||
for (const [projectId, order] of Object.entries(parsed.data)) {
|
||||
next.set(projectId, order);
|
||||
}
|
||||
return next;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
};
|
||||
|
||||
export const useSessionProjectViewState = ({
|
||||
isVSCode,
|
||||
projects,
|
||||
}: SessionProjectViewStateArgs) => {
|
||||
const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
|
||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(() => (
|
||||
parseStringSet(safeStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY))
|
||||
));
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => (
|
||||
parseStringSet(safeStorage.getItem(GROUP_COLLAPSE_STORAGE_KEY))
|
||||
));
|
||||
const [groupOrderByProject, setGroupOrderByProject] = React.useState<Map<string, string[]>>(() => (
|
||||
parseGroupOrder(safeStorage.getItem(GROUP_ORDER_STORAGE_KEY))
|
||||
));
|
||||
const ignoreIntersectionUntil = React.useRef<number>(0);
|
||||
const groupCollapseDirty = React.useRef(false);
|
||||
const groupOrderDirty = React.useRef(false);
|
||||
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
||||
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
|
||||
|
||||
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
||||
if (isVSCode) return;
|
||||
const collapsed = pendingCollapsedProjects.current;
|
||||
pendingCollapsedProjects.current = null;
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
if (!collapsed) return;
|
||||
|
||||
const { projects: storedProjects } = useProjectsStore.getState();
|
||||
const updatedProjects = storedProjects.map((project) => ({
|
||||
...project,
|
||||
sidebarCollapsed: collapsed.has(project.id),
|
||||
}));
|
||||
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
|
||||
}, [isVSCode]);
|
||||
|
||||
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
|
||||
if (!globalThis.window || isVSCode) return;
|
||||
pendingCollapsedProjects.current = collapsed;
|
||||
if (persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
|
||||
flushCollapsedProjectsPersist();
|
||||
}, 700);
|
||||
}, [flushCollapsedProjectsPersist, isVSCode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (globalThis.window && persistCollapsedProjectsTimer.current !== null) {
|
||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||
}
|
||||
persistCollapsedProjectsTimer.current = null;
|
||||
pendingCollapsedProjects.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!groupOrderDirty.current) return;
|
||||
try {
|
||||
safeStorage.setItem(GROUP_ORDER_STORAGE_KEY, JSON.stringify(Object.fromEntries(groupOrderByProject.entries())));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [groupOrderByProject, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!groupCollapseDirty.current) return;
|
||||
try {
|
||||
safeStorage.setItem(GROUP_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(collapsedGroups)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [collapsedGroups, safeStorage]);
|
||||
|
||||
const collapseAllProjects = React.useCallback(() => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
groupCollapseDirty.current = true;
|
||||
setCollapsedGroups(new Set());
|
||||
setCollapsedProjects(() => {
|
||||
const allIds = new Set(projects.map((project) => project.id));
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(allIds)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
scheduleCollapsedProjectsPersist(allIds);
|
||||
return allIds;
|
||||
});
|
||||
}, [projects, safeStorage, scheduleCollapsedProjectsPersist]);
|
||||
|
||||
const expandAllProjects = React.useCallback(() => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
groupCollapseDirty.current = true;
|
||||
setCollapsedGroups(new Set());
|
||||
setCollapsedProjects(() => {
|
||||
const empty = new Set<string>();
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([]));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
scheduleCollapsedProjectsPersist(empty);
|
||||
return empty;
|
||||
});
|
||||
}, [safeStorage, scheduleCollapsedProjectsPersist]);
|
||||
|
||||
const toggleProject = React.useCallback((projectId: string) => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
setCollapsedProjects((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(projectId)) next.delete(projectId);
|
||||
else next.add(projectId);
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
scheduleCollapsedProjectsPersist(next);
|
||||
return next;
|
||||
});
|
||||
}, [safeStorage, scheduleCollapsedProjectsPersist]);
|
||||
|
||||
const toggleGroup = React.useCallback((key: string) => {
|
||||
groupCollapseDirty.current = true;
|
||||
setCollapsedGroups((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const updateGroupOrderByProject = React.useCallback<React.Dispatch<React.SetStateAction<Map<string, string[]>>>>((update) => {
|
||||
groupOrderDirty.current = true;
|
||||
setGroupOrderByProject(update);
|
||||
}, []);
|
||||
|
||||
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
|
||||
const state = React.useMemo(() => ({
|
||||
collapsedProjects,
|
||||
collapsedGroups,
|
||||
groupOrderByProject,
|
||||
}), [collapsedGroups, collapsedProjects, groupOrderByProject]);
|
||||
const actions = React.useMemo(() => ({
|
||||
setCollapsedProjects,
|
||||
toggleProject,
|
||||
collapseAllProjects,
|
||||
expandAllProjects,
|
||||
scheduleCollapsedProjectsPersist,
|
||||
setCollapsedGroups,
|
||||
toggleGroup,
|
||||
setGroupOrderByProject: updateGroupOrderByProject,
|
||||
getOrderedGroups,
|
||||
}), [collapseAllProjects, expandAllProjects, getOrderedGroups, scheduleCollapsedProjectsPersist, toggleGroup, toggleProject, updateGroupOrderByProject]);
|
||||
|
||||
return { state, actions };
|
||||
};
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
|
||||
@@ -161,10 +162,10 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
section.groups.forEach((group) => {
|
||||
const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery);
|
||||
const matchedSessionCount = countNodes(filteredNodes);
|
||||
const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery);
|
||||
const groupMatches = matchesRankQuery([buildGroupSearchText(group)], normalizedSessionSearchQuery);
|
||||
const scopeKey = normalizePath(group.directory ?? null);
|
||||
const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : [];
|
||||
const folderNameMatchCount = scopeFolders.filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length;
|
||||
const folderNameMatchCount = scopeFolders.filter((folder) => matchesRankQuery([folder.name], normalizedSessionSearchQuery)).length;
|
||||
|
||||
result.set(group, {
|
||||
filteredNodes,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { normalizePath } from './utils';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
// In-memory first-seen tracker for worktree directories. Worktree metadata
|
||||
// carries no creation time, so we record when a path first appears during
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatDirectoryName } from '@/lib/utils';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { SidebarActivitySections } from './SidebarActivitySections';
|
||||
import { deriveRecentActivitySections, type RecentSessionLocation } from './activitySections';
|
||||
import type { ActivityItem } from './SidebarActivitySections';
|
||||
import type { SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
import type { SessionNode } from '../types';
|
||||
import { formatProjectLabel, normalizePath } from '../utils';
|
||||
|
||||
type Props = {
|
||||
projects: { id: string; label?: string; normalizedPath: string }[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
gitBranches: Map<string, string | null>;
|
||||
homeDirectory: string | null;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
isDesktopShellRuntime: boolean;
|
||||
sessions: Session[];
|
||||
childrenMap: ReadonlyMap<string, readonly Session[]>;
|
||||
pinnedSessionIds: Set<string>;
|
||||
recentSessions: Session[];
|
||||
expandedParents: Set<string>;
|
||||
notifyOnSubtasks: boolean;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
copiedSessionId: string | null;
|
||||
openSidebarMenuKey: string | null;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
chatSessions: Session[];
|
||||
renderChatsSection: (items: ActivityItem[]) => React.ReactNode;
|
||||
onNewChat: () => void;
|
||||
showRecentSection: boolean;
|
||||
} & Pick<SessionTreeItemProps,
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'setSessionSearchQuery'
|
||||
| 'setIsSessionSearchOpen'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
>;
|
||||
|
||||
export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||
const {
|
||||
projects,
|
||||
availableWorktreesByProject,
|
||||
gitBranches,
|
||||
homeDirectory,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
isDesktopShellRuntime,
|
||||
sessions,
|
||||
childrenMap,
|
||||
pinnedSessionIds,
|
||||
recentSessions,
|
||||
chatSessions,
|
||||
showRecentSection,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const sessionLocationById = React.useMemo(() => {
|
||||
const locations = new Map<string, RecentSessionLocation>();
|
||||
for (const session of sessions) {
|
||||
const directory = normalizePath(session.directory ?? null);
|
||||
if (!directory) continue;
|
||||
let owner: Props['projects'][number] | null = null;
|
||||
let ownerLength = -1;
|
||||
for (const project of projects) {
|
||||
const projectPath = normalizePath(project.normalizedPath);
|
||||
if (projectPath && (directory === projectPath || directory.startsWith(`${projectPath}/`)) && projectPath.length > ownerLength) {
|
||||
owner = project;
|
||||
ownerLength = projectPath.length;
|
||||
}
|
||||
}
|
||||
if (!owner) continue;
|
||||
const worktree = availableWorktreesByProject.get(owner.normalizedPath)?.find((entry) => normalizePath(entry.path) === directory);
|
||||
const projectLabel = formatProjectLabel(owner.label?.trim() || formatDirectoryName(owner.normalizedPath, homeDirectory) || owner.normalizedPath);
|
||||
const branch = worktree?.branch?.trim() || gitBranches.get(directory)?.trim() || null;
|
||||
locations.set(session.id, {
|
||||
projectId: owner.id,
|
||||
groupDirectory: directory,
|
||||
projectLabel,
|
||||
branchLabel: branch && branch !== 'HEAD' && branch !== projectLabel ? branch : null,
|
||||
});
|
||||
}
|
||||
return locations;
|
||||
}, [availableWorktreesByProject, sessions, gitBranches, homeDirectory, projects]);
|
||||
const getSessionLocation = React.useCallback(
|
||||
(sessionId: string) => sessionLocationById.get(sessionId) ?? null,
|
||||
[sessionLocationById],
|
||||
);
|
||||
const getSessionNode = React.useCallback(
|
||||
(session: Session): SessionNode => ({
|
||||
session,
|
||||
children: (childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({
|
||||
session: child,
|
||||
children: [],
|
||||
worktree: null,
|
||||
})),
|
||||
worktree: null,
|
||||
}),
|
||||
[childrenMap],
|
||||
);
|
||||
const recentSections = React.useMemo(() => deriveRecentActivitySections({
|
||||
sessions: recentSessions,
|
||||
getSessionLocation,
|
||||
getSessionNode,
|
||||
query: hasSessionSearchQuery ? normalizedSessionSearchQuery : '',
|
||||
}), [getSessionLocation, getSessionNode, hasSessionSearchQuery, normalizedSessionSearchQuery, recentSessions]);
|
||||
const sections = React.useMemo(() => [
|
||||
{
|
||||
key: 'chats' as const,
|
||||
title: t('sessions.sidebar.activity.chatsTitle'),
|
||||
items: chatSessions.map((session) => ({
|
||||
node: getSessionNode(session),
|
||||
projectId: null,
|
||||
groupDirectory: session.directory ?? null,
|
||||
secondaryMeta: null,
|
||||
})),
|
||||
},
|
||||
...(showRecentSection ? recentSections.map((section) => ({ ...section, title: t('sessions.sidebar.activity.recentTitle') })) : []),
|
||||
], [chatSessions, getSessionNode, recentSections, showRecentSection, t]);
|
||||
return (
|
||||
<SidebarActivitySections
|
||||
sections={sections}
|
||||
variant="section"
|
||||
isDesktopShellRuntime={isDesktopShellRuntime}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={props.expandedParents}
|
||||
hasSessionSearchQuery={props.hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={props.normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={props.notifyOnSubtasks}
|
||||
editingId={props.editingId}
|
||||
editTitle={props.editTitle}
|
||||
copiedSessionId={props.copiedSessionId}
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
onNewChat={props.onNewChat}
|
||||
renderChatsSection={props.renderChatsSection}
|
||||
setEditingId={props.setEditingId}
|
||||
setEditTitle={props.setEditTitle}
|
||||
toggleParent={props.toggleParent}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
allowReselect={props.allowReselect}
|
||||
onSessionSelected={props.onSessionSelected}
|
||||
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||
sessionSearchQuery={props.sessionSearchQuery}
|
||||
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+94
-39
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SessionNode } from './types';
|
||||
import type { SessionNode } from '../types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
collectSubtreeContainingId,
|
||||
computeNodeStructureKey,
|
||||
resolveMenuOpenSessionId,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
} from '../sessions/sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
||||
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
|
||||
type ActivityItem = {
|
||||
export type ActivityItem = {
|
||||
node: SessionNode;
|
||||
projectId: string | null;
|
||||
groupDirectory: string | null;
|
||||
@@ -22,31 +23,48 @@ type ActivityItem = {
|
||||
};
|
||||
|
||||
type ActivitySection = {
|
||||
key: 'active-now';
|
||||
key: 'active-now' | 'chats';
|
||||
title: string;
|
||||
items: ActivityItem[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sections: ActivitySection[];
|
||||
renderSessionNode: (
|
||||
node: SessionNode,
|
||||
depth?: number,
|
||||
groupDirectory?: string | null,
|
||||
projectId?: string | null,
|
||||
archivedBucket?: boolean,
|
||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
editingId: string | null;
|
||||
openSidebarMenuKey: string | null;
|
||||
expansionState?: ReadonlySet<string>;
|
||||
variant?: 'section' | 'flat';
|
||||
initialVisibleCount?: number;
|
||||
batchSize?: number;
|
||||
isDesktopShellRuntime: boolean;
|
||||
};
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
notifyOnSubtasks: boolean;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
copiedSessionId: string | null;
|
||||
openSidebarMenuKey: string | null;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
onNewChat?: () => void;
|
||||
renderChatsSection?: (items: ActivityItem[]) => React.ReactNode;
|
||||
} & Pick<SessionTreeItemProps,
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
| 'allowReselect'
|
||||
| 'onSessionSelected'
|
||||
| 'isSessionSearchOpen'
|
||||
| 'sessionSearchQuery'
|
||||
| 'setSessionSearchQuery'
|
||||
| 'setIsSessionSearchOpen'
|
||||
| 'deleteSessionConfirm'
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
>;
|
||||
|
||||
type RenderExtras = SessionNodeRenderExtras;
|
||||
|
||||
@@ -55,14 +73,12 @@ const MAX_VISIBLE_RECENT_SESSIONS = 7;
|
||||
export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
const {
|
||||
sections,
|
||||
renderSessionNode,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
variant = 'section',
|
||||
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const { pinnedSessionIds } = props;
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||
@@ -105,8 +121,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
|
||||
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
|
||||
const subtreeContainsEditing = new Set<string>();
|
||||
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
|
||||
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
|
||||
collectSubtreeContainingId(nodes, props.editingId, subtreeContainsEditing);
|
||||
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, props.openSidebarMenuKey, 'recent', false);
|
||||
const nodeStructureKeyByNode = new WeakMap<SessionNode, string>();
|
||||
const visit = (node: SessionNode): void => {
|
||||
nodeStructureKeyByNode.set(node, computeNodeStructureKey(node));
|
||||
@@ -127,9 +143,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
|
||||
childRenderExtrasFor,
|
||||
});
|
||||
}, [editingId, openSidebarMenuKey]);
|
||||
}, [props.editingId, props.openSidebarMenuKey]);
|
||||
|
||||
const visibleSections = sections.filter((section) => section.items.length > 0);
|
||||
const visibleSections = sections.filter((section) => section.items.length > 0 || section.key === 'chats');
|
||||
if (visibleSections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -146,17 +162,45 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
);
|
||||
const visibleItems = section.items.slice(0, visibleLimit);
|
||||
const remainingCount = section.items.length - visibleItems.length;
|
||||
const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
|
||||
const usesCustomRenderer = section.key === 'chats' && Boolean(props.renderChatsSection);
|
||||
const canShowFewer = !usesCustomRenderer && !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
|
||||
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
|
||||
const renderItem = (item: ActivityItem) => renderSessionNode(
|
||||
item.node,
|
||||
0,
|
||||
item.groupDirectory,
|
||||
item.projectId,
|
||||
false,
|
||||
item.secondaryMeta,
|
||||
'recent',
|
||||
getRenderExtras(item.node),
|
||||
const renderItem = (item: ActivityItem) => (
|
||||
<SessionTreeItem
|
||||
key={item.node.session.id}
|
||||
node={item.node}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={props.expandedParents}
|
||||
hasSessionSearchQuery={props.hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={props.normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={props.notifyOnSubtasks}
|
||||
editingId={props.editingId}
|
||||
editTitle={props.editTitle}
|
||||
copiedSessionId={props.copiedSessionId}
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
groupDirectory={item.groupDirectory}
|
||||
projectId={item.projectId}
|
||||
secondaryMeta={item.secondaryMeta}
|
||||
renderContext="recent"
|
||||
renderExtras={getRenderExtras(item.node)}
|
||||
setEditingId={props.setEditingId}
|
||||
setEditTitle={props.setEditTitle}
|
||||
toggleParent={props.toggleParent}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
allowReselect={props.allowReselect}
|
||||
onSessionSelected={props.onSessionSelected}
|
||||
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||
sessionSearchQuery={props.sessionSearchQuery}
|
||||
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad}
|
||||
/>
|
||||
);
|
||||
|
||||
if (flatVariant) {
|
||||
@@ -179,28 +223,39 @@ 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' ? '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 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); props.onNewChat?.(); }}
|
||||
className={cn('absolute right-0.5 top-1/2 z-10 inline-flex h-6 w-6 -translate-y-1/2 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', 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>
|
||||
) : null}
|
||||
</div>
|
||||
{!isCollapsed ? (
|
||||
<div className={cn('space-y-0.5')}>
|
||||
{visibleItems.map(renderItem)}
|
||||
{remainingCount > 0 ? (
|
||||
{usesCustomRenderer ? props.renderChatsSection?.(section.items) : visibleItems.map(renderItem)}
|
||||
{!usesCustomRenderer && remainingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
||||
+37
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { deriveRecentSessions } from './activitySections';
|
||||
import { deriveRecentActivitySections, deriveRecentSessions } from './activitySections';
|
||||
|
||||
const NOW = 200_000_000;
|
||||
const RECENT = NOW - (48 * 60 * 60 * 1000);
|
||||
@@ -37,3 +37,39 @@ describe('deriveRecentSessions', () => {
|
||||
expect(deriveRecentSessions([oldSession, recentSession], new Set(), NOW)).toEqual([recentSession]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveRecentActivitySections', () => {
|
||||
test('filters recent roots by search text and falls back to topology metadata', () => {
|
||||
const matching = {
|
||||
...session('matching', { updated: RECENT }),
|
||||
title: 'Deploy release',
|
||||
directory: '/workspace/app/worktrees/release',
|
||||
};
|
||||
const excluded = {
|
||||
...session('excluded', { updated: RECENT }),
|
||||
title: 'Investigate failure',
|
||||
directory: '/workspace/app',
|
||||
};
|
||||
|
||||
const sections = deriveRecentActivitySections({
|
||||
sessions: [matching, excluded],
|
||||
getSessionLocation: (sessionId) => sessionId === matching.id ? {
|
||||
projectId: 'app',
|
||||
groupDirectory: '/workspace/app/worktrees/release',
|
||||
projectLabel: 'App',
|
||||
branchLabel: 'release',
|
||||
} : null,
|
||||
query: 'deploy',
|
||||
});
|
||||
|
||||
expect(sections).toEqual([{
|
||||
key: 'active-now',
|
||||
items: [{
|
||||
node: { session: matching, children: [], worktree: null },
|
||||
projectId: 'app',
|
||||
groupDirectory: '/workspace/app/worktrees/release',
|
||||
secondaryMeta: { projectLabel: 'App', branchLabel: 'release' },
|
||||
}],
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
export type RecentSessionLocation = {
|
||||
projectId: string | null;
|
||||
groupDirectory: string | null;
|
||||
projectLabel: string | null;
|
||||
branchLabel: string | null;
|
||||
};
|
||||
|
||||
type RecentActivitySection = {
|
||||
key: 'active-now';
|
||||
items: Array<{
|
||||
node: SessionNode;
|
||||
projectId: string | null;
|
||||
groupDirectory: string | null;
|
||||
secondaryMeta: { projectLabel?: string | null; branchLabel?: string | null } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
const isSubtaskSession = (session: Session): boolean => {
|
||||
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
};
|
||||
|
||||
const isArchivedSession = (session: Session): boolean => {
|
||||
return Boolean(session.time?.archived);
|
||||
};
|
||||
|
||||
const getSessionUpdatedAt = (session: Session): number => {
|
||||
const updated = session.time?.updated;
|
||||
const created = session.time?.created;
|
||||
if (typeof updated === 'number' && Number.isFinite(updated)) {
|
||||
return updated;
|
||||
}
|
||||
if (typeof created === 'number' && Number.isFinite(created)) {
|
||||
return created;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Recent contains non-archived root sessions that are active now or were
|
||||
// updated within the retention window. The caller applies shared lifecycle
|
||||
// ordering after this membership filter; batching ("Show more") handles long
|
||||
// windows in the UI.
|
||||
export const deriveRecentSessions = (
|
||||
sessions: Session[],
|
||||
activeSessionIds: ReadonlySet<string>,
|
||||
now = Date.now(),
|
||||
): Session[] => {
|
||||
const minUpdatedAt = now - RECENT_SESSION_MAX_AGE_MS;
|
||||
return sessions.filter((session) => {
|
||||
if (isArchivedSession(session) || isSubtaskSession(session)) {
|
||||
return false;
|
||||
}
|
||||
return activeSessionIds.has(session.id) || getSessionUpdatedAt(session) >= minUpdatedAt;
|
||||
});
|
||||
};
|
||||
|
||||
export const deriveRecentActivitySections = ({
|
||||
sessions,
|
||||
getSessionLocation,
|
||||
getSessionNode,
|
||||
query,
|
||||
}: {
|
||||
sessions: Session[];
|
||||
getSessionLocation: (sessionId: string) => RecentSessionLocation | null;
|
||||
getSessionNode?: (session: Session) => SessionNode;
|
||||
query: string;
|
||||
}): RecentActivitySection[] => [{
|
||||
key: 'active-now',
|
||||
items: sessions.flatMap((session) => {
|
||||
const title = typeof session.title === 'string' ? session.title.toLowerCase() : '';
|
||||
if (query && !title.includes(query)) return [];
|
||||
const location = getSessionLocation(session.id);
|
||||
return [{
|
||||
node: getSessionNode?.(session) ?? { session, children: [], worktree: null },
|
||||
projectId: location?.projectId ?? null,
|
||||
groupDirectory: location?.groupDirectory ?? session.directory ?? null,
|
||||
secondaryMeta: location ? {
|
||||
projectLabel: location.projectLabel,
|
||||
branchLabel: location.branchLabel,
|
||||
} : null,
|
||||
}];
|
||||
}),
|
||||
}];
|
||||
@@ -0,0 +1,549 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
buildSessionWorktreeMenuTargets,
|
||||
commitDiscoveredRawWorktreesByProject,
|
||||
getSessionWorktreeMenuState,
|
||||
markRawWorktreesByProjectMutation,
|
||||
startSessionWorktreeMenuLoad,
|
||||
} from './sessionWorktreeMenu';
|
||||
|
||||
const rawScope = (runtimeKey: string | null, entries: Array<[string, WorktreeMetadata[]]>) => ({
|
||||
current: {
|
||||
runtimeKey,
|
||||
revision: 0,
|
||||
worktreesByProject: new Map<string, WorktreeMetadata[]>(entries),
|
||||
},
|
||||
});
|
||||
|
||||
const worktree = (overrides: Partial<WorktreeMetadata> = {}): WorktreeMetadata => ({
|
||||
path: '/repo-feature',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'feature',
|
||||
label: 'feature',
|
||||
name: 'feature',
|
||||
worktreeStatus: 'ready',
|
||||
worktreeSource: 'existing',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createDeferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('buildSessionWorktreeMenuTargets', () => {
|
||||
test('adds the canonical main worktree, includes the current source, dedupes by path, and sorts linked targets', () => {
|
||||
const targets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: '/repo-linked',
|
||||
discoveredWorktrees: [
|
||||
worktree({ path: '/repo-zebra', branch: 'zebra', label: 'zebra', name: 'zebra' }),
|
||||
worktree({ path: '/repo-alpha', branch: 'alpha', label: 'alpha', name: 'alpha' }),
|
||||
worktree({ path: '/repo-current', branch: 'current', label: 'current', name: 'current' }),
|
||||
worktree({ path: '/repo-alpha/', branch: 'alpha', label: 'alpha duplicate', name: 'alpha-duplicate' }),
|
||||
],
|
||||
sourceDirectory: '/repo-current/',
|
||||
currentWorktree: worktree({
|
||||
path: '/repo-current',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'current',
|
||||
label: 'Current branch',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(targets.map((target) => ({
|
||||
path: target.metadata.path,
|
||||
isPrimary: target.isPrimary,
|
||||
isCurrent: target.isCurrent,
|
||||
}))).toEqual([
|
||||
{ path: '/repo', isPrimary: true, isCurrent: false },
|
||||
{ path: '/repo-alpha', isPrimary: false, isCurrent: false },
|
||||
{ path: '/repo-current', isPrimary: false, isCurrent: true },
|
||||
{ path: '/repo-zebra', isPrimary: false, isCurrent: false },
|
||||
]);
|
||||
expect(targets[0]?.metadata.worktreeStatus).toBe('ready');
|
||||
expect(targets[0]?.metadata.worktreeSource).toBe('existing');
|
||||
});
|
||||
|
||||
test('prefers discovered primary metadata instead of synthetic fallback metadata', () => {
|
||||
const targets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: '/repo-linked',
|
||||
discoveredWorktrees: [
|
||||
worktree({
|
||||
path: '/repo',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'main',
|
||||
label: 'main',
|
||||
name: 'repo-primary',
|
||||
headState: 'branch',
|
||||
}),
|
||||
],
|
||||
sourceDirectory: '/repo-linked',
|
||||
currentWorktree: worktree({
|
||||
path: '/repo-linked',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'feature',
|
||||
label: 'feature',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(targets[0]?.isPrimary).toBe(true);
|
||||
expect(targets[0]?.metadata.path).toBe('/repo');
|
||||
expect(targets[0]?.metadata.branch).toBe('main');
|
||||
expect(targets[0]?.metadata.label).toBe('main');
|
||||
expect(targets[0]?.metadata.name).toBe('repo-primary');
|
||||
expect(targets[0]?.metadata.headState).toBe('branch');
|
||||
});
|
||||
|
||||
test('sorts linked targets by effective compact label when branch is missing', () => {
|
||||
const targets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: '/repo',
|
||||
discoveredWorktrees: [
|
||||
worktree({ path: '/repo-zed', branch: '', label: '', name: 'zed' }),
|
||||
worktree({ path: '/repo-alpha', branch: '', label: '', name: 'alpha' }),
|
||||
worktree({ path: '/repo-beta', branch: 'beta', label: 'beta', name: 'beta' }),
|
||||
],
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', projectDirectory: '/repo', branch: '', label: '', name: 'current' }),
|
||||
});
|
||||
|
||||
expect(targets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-alpha',
|
||||
'/repo-beta',
|
||||
'/repo-current',
|
||||
'/repo-zed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses the owning project root branch for a synthetic primary when git omits the queried checkout', () => {
|
||||
const targets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: '/repo',
|
||||
discoveredWorktrees: [
|
||||
worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }),
|
||||
],
|
||||
sourceDirectory: '/repo-feature',
|
||||
currentWorktree: worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }),
|
||||
projectRootBranch: 'main',
|
||||
});
|
||||
|
||||
expect(targets[0]?.isPrimary).toBe(true);
|
||||
expect(targets[0]?.metadata.path).toBe('/repo');
|
||||
expect(targets[0]?.metadata.branch).toBe('main');
|
||||
expect(targets[0]?.metadata.label).toBe('main');
|
||||
expect(targets[0]?.metadata.headState).toBe('branch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commitDiscoveredRawWorktreesByProject', () => {
|
||||
test('rejects an older aggregate commit after a newer targeted mutation and requests one bounded rediscovery', () => {
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo', [worktree({ path: '/repo-old', projectDirectory: '/repo', branch: 'old', label: 'old' })]],
|
||||
]);
|
||||
const reruns: string[] = [];
|
||||
const published: Array<unknown> = [];
|
||||
const capturedRevision = rawRef.current.revision;
|
||||
|
||||
markRawWorktreesByProjectMutation(rawRef, 'runtime-1');
|
||||
|
||||
const committed = commitDiscoveredRawWorktreesByProject({
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
runtimeKey: 'runtime-1',
|
||||
capturedRevision,
|
||||
nextRawWorktreesByProject: new Map([
|
||||
['/repo', [worktree({ path: '/repo-stale', projectDirectory: '/repo', branch: 'stale', label: 'stale' })]],
|
||||
]),
|
||||
publishedWorktreesByProject: new Map(),
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
projects: [{ id: 'owner', path: '/repo' }],
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push(next);
|
||||
},
|
||||
requestRediscovery: () => {
|
||||
reruns.push('rerun');
|
||||
},
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(reruns).toEqual(['rerun']);
|
||||
expect(rawRef.current.worktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-old']);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startSessionWorktreeMenuLoad', () => {
|
||||
test('returns cached targets immediately, forces only the owning project refresh, and publishes refreshed topology', async () => {
|
||||
const calls: Array<{ projectId: string; force: boolean }> = [];
|
||||
const published: Array<{ availableWorktrees: WorktreeMetadata[]; availableWorktreesByProject: Map<string, WorktreeMetadata[]> }> = [];
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]],
|
||||
['/repo-other', [worktree({ path: '/other-worktree', projectDirectory: '/repo-other', branch: 'other', label: 'other', name: 'other' })]],
|
||||
]);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: [
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
{ id: 'other', path: '/repo-other' },
|
||||
],
|
||||
getCurrentProjects: () => [
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
{ id: 'other', path: '/repo-other' },
|
||||
],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map(),
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async (project, options) => {
|
||||
calls.push({ projectId: project.id, force: options.force });
|
||||
return [
|
||||
worktree({ path: '/repo-new', branch: 'aaa', label: 'aaa', name: 'aaa' }),
|
||||
worktree({ path: '/repo-current', branch: 'current', label: 'current', name: 'current' }),
|
||||
];
|
||||
},
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push(next);
|
||||
},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-current',
|
||||
'/repo-existing',
|
||||
]);
|
||||
|
||||
const freshTargets = await load.refreshTargets;
|
||||
|
||||
expect(calls).toEqual([{ projectId: 'linked', force: true }]);
|
||||
expect(freshTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-new',
|
||||
'/repo-current',
|
||||
]);
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual([
|
||||
'/repo-new',
|
||||
'/repo-current',
|
||||
]);
|
||||
expect(published).toHaveLength(1);
|
||||
expect(published[0]?.availableWorktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual([
|
||||
'/repo-new',
|
||||
'/repo-current',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects refresh failures without mutating topology and keeps cached targets available for the menu', async () => {
|
||||
const published: Array<unknown> = [];
|
||||
const existing = worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' });
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo-linked', [existing]],
|
||||
]);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'linked', path: '/repo-linked' }],
|
||||
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]),
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => {
|
||||
throw new Error('git failed');
|
||||
},
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push(next);
|
||||
},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-current',
|
||||
'/repo-existing',
|
||||
]);
|
||||
const refreshError = await load.refreshTargets.catch((error) => error);
|
||||
expect(refreshError).toBeInstanceOf(Error);
|
||||
expect(refreshError.message).toBe('git failed');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')).toEqual([existing]);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
test('seeds an empty raw scope from published topology so a failed first refresh preserves prior topology', async () => {
|
||||
const publishedTopology = new Map<string, WorktreeMetadata[]>([
|
||||
['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]],
|
||||
]);
|
||||
const rawRef = rawScope(null, []);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'linked', path: '/repo-linked' }],
|
||||
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => publishedTopology,
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => {
|
||||
throw new Error('git failed');
|
||||
},
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => true,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: () => {
|
||||
throw new Error('should not publish on failed refresh');
|
||||
},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-current',
|
||||
'/repo-existing',
|
||||
]);
|
||||
const refreshError = await load.refreshTargets.catch((error) => error);
|
||||
expect(refreshError).toBeInstanceOf(Error);
|
||||
expect(refreshError.message).toBe('git failed');
|
||||
expect(rawRef.current.runtimeKey).toBe('runtime-1');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']);
|
||||
});
|
||||
|
||||
test('applies a non-owner shared-repository refresh to the owner raw and published topology', async () => {
|
||||
const published: Array<{ availableWorktreesByProject: Map<string, WorktreeMetadata[]> }> = [];
|
||||
const ownerExisting = worktree({ path: '/repo-old', branch: 'old', label: 'old', name: 'old' });
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo', [ownerExisting]],
|
||||
['/repo-linked', [worktree({ path: '/repo-other-stale', branch: 'stale', label: 'stale', name: 'stale' })]],
|
||||
]);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-linked',
|
||||
currentWorktree: worktree({ path: '/repo-linked', projectDirectory: '/repo', branch: 'feature', label: 'feature' }),
|
||||
},
|
||||
{
|
||||
projects: [
|
||||
{ id: 'owner', path: '/repo' },
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
],
|
||||
getCurrentProjects: () => [
|
||||
{ id: 'owner', path: '/repo' },
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map([['/repo', [ownerExisting]]]),
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => [
|
||||
worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' }),
|
||||
],
|
||||
partitionWorktreesByRegisteredProject: (projects, worktreesByProject) => {
|
||||
const ownerPath = projects[0]!.path;
|
||||
return new Map([[ownerPath, worktreesByProject.get(ownerPath) ?? []]]);
|
||||
},
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push({ availableWorktreesByProject: next.availableWorktreesByProject });
|
||||
},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
await load.refreshTargets;
|
||||
|
||||
expect(rawRef.current.worktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-new']);
|
||||
expect(published[0]?.availableWorktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-new']);
|
||||
});
|
||||
|
||||
test('re-seeds raw topology on runtime change and ignores stale completions', async () => {
|
||||
let runtimeKey = 'runtime-2';
|
||||
const refreshDeferred = createDeferred<WorktreeMetadata[]>();
|
||||
const published: Array<unknown> = [];
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/old-runtime-repo', [worktree({ path: '/old-runtime-worktree', projectDirectory: '/old-runtime-repo' })]],
|
||||
]);
|
||||
const publishedCurrentRuntime = new Map<string, WorktreeMetadata[]>([
|
||||
['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]],
|
||||
]);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'linked', path: '/repo-linked' }],
|
||||
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => publishedCurrentRuntime,
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => refreshDeferred.promise,
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push(next);
|
||||
},
|
||||
getRuntimeKey: () => runtimeKey,
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-current',
|
||||
'/repo-existing',
|
||||
]);
|
||||
expect(rawRef.current.runtimeKey).toBe('runtime-2');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']);
|
||||
|
||||
runtimeKey = 'runtime-3';
|
||||
refreshDeferred.resolve([worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' })]);
|
||||
|
||||
const refreshError = await load.refreshTargets.catch((error) => error);
|
||||
expect(refreshError).toBeInstanceOf(Error);
|
||||
expect(refreshError.message).toBe('Runtime changed during worktree refresh');
|
||||
expect(rawRef.current.runtimeKey).toBe('runtime-2');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects a deferred refresh when the owning project is removed before commit', async () => {
|
||||
const refreshDeferred = createDeferred<WorktreeMetadata[]>();
|
||||
const existing = worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' });
|
||||
const published: Array<{ availableWorktreesByProject: Map<string, WorktreeMetadata[]> }> = [];
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo-linked', [existing]],
|
||||
]);
|
||||
let currentProjects = [{ id: 'linked', path: '/repo-linked' }];
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: currentProjects,
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]),
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => refreshDeferred.promise,
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push({ availableWorktreesByProject: next.availableWorktreesByProject });
|
||||
},
|
||||
getCurrentProjects: () => currentProjects,
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
currentProjects = [];
|
||||
refreshDeferred.resolve([
|
||||
worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' }),
|
||||
]);
|
||||
|
||||
const refreshError = await load.refreshTargets.catch((error) => error);
|
||||
|
||||
expect(refreshError).toBeInstanceOf(Error);
|
||||
expect(refreshError.message).toBe('Project removed during worktree refresh');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')).toEqual([existing]);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
test('falls back to resolving the owning configured project from the source directory when projectId is missing', async () => {
|
||||
const calls: string[] = [];
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: null,
|
||||
sourceDirectory: '/repo-feature',
|
||||
currentWorktree: worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }),
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'owner', path: '/repo' }],
|
||||
getCurrentProjects: () => [{ id: 'owner', path: '/repo' }],
|
||||
rawWorktreesByProjectRef: rawScope('runtime-1', []),
|
||||
getPublishedWorktreesByProject: () => new Map(),
|
||||
resolveProject: (directory) => {
|
||||
calls.push(directory);
|
||||
return { id: 'owner', path: '/repo' };
|
||||
},
|
||||
listProjectWorktrees: async (project) => [
|
||||
worktree({ path: '/repo-another', projectDirectory: project.path, branch: 'another', label: 'another', name: 'another' }),
|
||||
],
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: () => {},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: 'main',
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual(['/repo-feature']);
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual(['/repo', '/repo-feature']);
|
||||
const refreshTargets = await load.refreshTargets;
|
||||
expect(refreshTargets.map((target) => ({
|
||||
path: target.metadata.path,
|
||||
branch: target.metadata.branch,
|
||||
}))).toEqual([
|
||||
{ path: '/repo', branch: 'main' },
|
||||
{ path: '/repo-another', branch: 'another' },
|
||||
{ path: '/repo-feature', branch: 'feature' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionWorktreeMenuState', () => {
|
||||
test('keeps the new worktree action available when refresh fails without cached targets', () => {
|
||||
expect(getSessionWorktreeMenuState({
|
||||
targets: [],
|
||||
isRefreshing: false,
|
||||
loadFailed: true,
|
||||
})).toEqual({
|
||||
refreshState: 'error',
|
||||
showNewWorktreeAction: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
|
||||
export type SessionWorktreeMenuTarget = {
|
||||
metadata: WorktreeMetadata;
|
||||
isPrimary: boolean;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export type StartSessionWorktreeMenuLoadArgs = {
|
||||
projectId: string | null;
|
||||
sourceDirectory: string | null;
|
||||
currentWorktree: WorktreeMetadata | null;
|
||||
};
|
||||
|
||||
export type StartSessionWorktreeMenuLoadResult = {
|
||||
cachedTargets: SessionWorktreeMenuTarget[];
|
||||
refreshTargets: Promise<SessionWorktreeMenuTarget[]>;
|
||||
};
|
||||
|
||||
type SessionWorktreeMenuState = {
|
||||
refreshState: 'loading' | 'error' | null;
|
||||
showNewWorktreeAction: boolean;
|
||||
};
|
||||
|
||||
type StartSessionWorktreeMenuLoadDependencies = {
|
||||
projects: ReadonlyArray<ProjectRef>;
|
||||
getCurrentProjects: () => ReadonlyArray<ProjectRef>;
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
|
||||
getPublishedWorktreesByProject: () => Map<string, WorktreeMetadata[]>;
|
||||
resolveProject: (directory: string) => ProjectRef | null;
|
||||
listProjectWorktrees: (project: ProjectRef, options: { force: true }) => Promise<WorktreeMetadata[]>;
|
||||
partitionWorktreesByRegisteredProject: (
|
||||
projects: ReadonlyArray<Pick<ProjectRef, 'path'>>,
|
||||
worktreesByProject: ReadonlyMap<string, WorktreeMetadata[]>,
|
||||
) => Map<string, WorktreeMetadata[]>;
|
||||
worktreeMapsEqual: (
|
||||
a: Map<string, WorktreeMetadata[]>,
|
||||
b: Map<string, WorktreeMetadata[]>,
|
||||
) => boolean;
|
||||
recordWorktreesSeen: (paths: Iterable<string | null | undefined>, seenAt: number) => void;
|
||||
publishTopology: (next: {
|
||||
availableWorktrees: WorktreeMetadata[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
}) => void;
|
||||
getRuntimeKey: () => string;
|
||||
now: () => number;
|
||||
projectRootBranch: string | null;
|
||||
};
|
||||
|
||||
type RequestRediscovery = () => void;
|
||||
|
||||
export type RawWorktreesByProjectScope = {
|
||||
runtimeKey: string | null;
|
||||
revision: number;
|
||||
worktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
};
|
||||
|
||||
export const markRawWorktreesByProjectMutation = (
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope },
|
||||
runtimeKey: string,
|
||||
): number => {
|
||||
if (rawWorktreesByProjectRef.current.runtimeKey !== runtimeKey) {
|
||||
return rawWorktreesByProjectRef.current.revision;
|
||||
}
|
||||
rawWorktreesByProjectRef.current = {
|
||||
...rawWorktreesByProjectRef.current,
|
||||
revision: rawWorktreesByProjectRef.current.revision + 1,
|
||||
};
|
||||
return rawWorktreesByProjectRef.current.revision;
|
||||
};
|
||||
|
||||
const cloneWorktreesByProject = (
|
||||
worktreesByProject: ReadonlyMap<string, WorktreeMetadata[]>,
|
||||
): Map<string, WorktreeMetadata[]> => {
|
||||
return new Map(
|
||||
[...worktreesByProject.entries()].map(([projectPath, worktrees]) => [projectPath, worktrees.map((worktree) => cloneMetadata(worktree))]),
|
||||
);
|
||||
};
|
||||
|
||||
export const ensureRawWorktreesByProjectScope = (args: {
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
|
||||
publishedWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
runtimeKey: string;
|
||||
}): RawWorktreesByProjectScope => {
|
||||
const shouldReseed = args.rawWorktreesByProjectRef.current.runtimeKey !== args.runtimeKey
|
||||
|| (args.rawWorktreesByProjectRef.current.worktreesByProject.size === 0 && args.publishedWorktreesByProject.size > 0);
|
||||
|
||||
if (shouldReseed) {
|
||||
args.rawWorktreesByProjectRef.current = {
|
||||
runtimeKey: args.runtimeKey,
|
||||
revision: args.rawWorktreesByProjectRef.current.runtimeKey === args.runtimeKey
|
||||
? args.rawWorktreesByProjectRef.current.revision
|
||||
: 0,
|
||||
worktreesByProject: cloneWorktreesByProject(args.publishedWorktreesByProject),
|
||||
};
|
||||
}
|
||||
|
||||
return args.rawWorktreesByProjectRef.current;
|
||||
};
|
||||
|
||||
const compareLinkedTargets = (a: SessionWorktreeMenuTarget, b: SessionWorktreeMenuTarget): number => {
|
||||
const aLabel = a.metadata.branch || a.metadata.name || a.metadata.label || a.metadata.path;
|
||||
const bLabel = b.metadata.branch || b.metadata.name || b.metadata.label || b.metadata.path;
|
||||
const labelCompare = aLabel.localeCompare(bLabel, undefined, { sensitivity: 'base' });
|
||||
if (labelCompare !== 0) {
|
||||
return labelCompare;
|
||||
}
|
||||
|
||||
return a.metadata.path.localeCompare(b.metadata.path, undefined, { sensitivity: 'base' });
|
||||
};
|
||||
|
||||
const buildFallbackLabel = (path: string): string => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
};
|
||||
|
||||
const cloneMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => ({
|
||||
...metadata,
|
||||
path: normalizePath(metadata.path) ?? metadata.path,
|
||||
projectDirectory: normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory,
|
||||
worktreeRoot: normalizePath(metadata.worktreeRoot ?? metadata.path) ?? metadata.worktreeRoot,
|
||||
});
|
||||
|
||||
const buildSyntheticWorktreeMetadata = (args: {
|
||||
path: string;
|
||||
projectDirectory: string;
|
||||
currentWorktree: WorktreeMetadata | null;
|
||||
projectRootBranch?: string | null;
|
||||
}): WorktreeMetadata => {
|
||||
const { currentWorktree, path, projectDirectory, projectRootBranch } = args;
|
||||
const currentPath = normalizePath(currentWorktree?.path ?? null);
|
||||
const isCurrentPath = currentPath === path;
|
||||
const syntheticBranch = isCurrentPath ? (currentWorktree?.branch ?? '') : (projectRootBranch ?? '');
|
||||
|
||||
const syntheticMetadata: WorktreeMetadata = {
|
||||
path,
|
||||
projectDirectory,
|
||||
branch: syntheticBranch,
|
||||
label: isCurrentPath
|
||||
? (currentWorktree?.label || currentWorktree?.branch || currentWorktree?.name || buildFallbackLabel(path))
|
||||
: (projectRootBranch || buildFallbackLabel(path)),
|
||||
name: isCurrentPath ? currentWorktree?.name : undefined,
|
||||
worktreeRoot: isCurrentPath
|
||||
? (normalizePath(currentWorktree?.worktreeRoot ?? path) ?? path)
|
||||
: path,
|
||||
worktreeStatus: isCurrentPath
|
||||
? (currentWorktree?.worktreeStatus ?? 'ready')
|
||||
: 'ready',
|
||||
worktreeSource: isCurrentPath
|
||||
? (currentWorktree?.worktreeSource ?? 'existing')
|
||||
: 'existing',
|
||||
headState: isCurrentPath ? currentWorktree?.headState : (projectRootBranch ? 'branch' : undefined),
|
||||
};
|
||||
|
||||
return isCurrentPath && currentWorktree
|
||||
? { ...currentWorktree, ...syntheticMetadata }
|
||||
: syntheticMetadata;
|
||||
};
|
||||
|
||||
export const buildSessionWorktreeMenuTargets = (args: {
|
||||
projectPath: string | null;
|
||||
discoveredWorktrees: ReadonlyArray<WorktreeMetadata>;
|
||||
sourceDirectory: string | null;
|
||||
currentWorktree: WorktreeMetadata | null;
|
||||
projectRootBranch?: string | null;
|
||||
}): SessionWorktreeMenuTarget[] => {
|
||||
const normalizedProjectPath = normalizePath(args.projectPath ?? null);
|
||||
const normalizedSourceDirectory = normalizePath(args.sourceDirectory ?? null)
|
||||
?? normalizePath(args.currentWorktree?.path ?? null);
|
||||
const discoveredPrimaryPath = normalizePath(
|
||||
args.discoveredWorktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory ?? null,
|
||||
);
|
||||
const currentPrimaryPath = normalizePath(args.currentWorktree?.projectDirectory ?? null);
|
||||
const primaryPath = discoveredPrimaryPath ?? currentPrimaryPath ?? normalizedProjectPath;
|
||||
|
||||
const targetsByPath = new Map<string, SessionWorktreeMenuTarget>();
|
||||
const pushTarget = (target: SessionWorktreeMenuTarget): void => {
|
||||
const normalizedPath = normalizePath(target.metadata.path ?? null);
|
||||
if (!normalizedPath || targetsByPath.has(normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
targetsByPath.set(normalizedPath, {
|
||||
...target,
|
||||
metadata: cloneMetadata({
|
||||
...target.metadata,
|
||||
path: normalizedPath,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
for (const worktree of args.discoveredWorktrees) {
|
||||
const normalizedPath = normalizePath(worktree.path ?? null);
|
||||
if (!normalizedPath) {
|
||||
continue;
|
||||
}
|
||||
pushTarget({
|
||||
metadata: cloneMetadata({
|
||||
...worktree,
|
||||
path: normalizedPath,
|
||||
projectDirectory: normalizePath(worktree.projectDirectory ?? null) ?? primaryPath ?? normalizedProjectPath ?? normalizedPath,
|
||||
}),
|
||||
isPrimary: primaryPath === normalizedPath,
|
||||
isCurrent: normalizedSourceDirectory === normalizedPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (primaryPath && !targetsByPath.has(primaryPath)) {
|
||||
pushTarget({
|
||||
metadata: buildSyntheticWorktreeMetadata({
|
||||
path: primaryPath,
|
||||
projectDirectory: primaryPath,
|
||||
currentWorktree: args.currentWorktree,
|
||||
projectRootBranch: args.projectRootBranch,
|
||||
}),
|
||||
isPrimary: true,
|
||||
isCurrent: normalizedSourceDirectory === primaryPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedSourceDirectory && !targetsByPath.has(normalizedSourceDirectory)) {
|
||||
pushTarget({
|
||||
metadata: buildSyntheticWorktreeMetadata({
|
||||
path: normalizedSourceDirectory,
|
||||
projectDirectory: primaryPath ?? normalizedProjectPath ?? normalizedSourceDirectory,
|
||||
currentWorktree: args.currentWorktree,
|
||||
projectRootBranch: args.projectRootBranch,
|
||||
}),
|
||||
isPrimary: primaryPath === normalizedSourceDirectory,
|
||||
isCurrent: true,
|
||||
});
|
||||
}
|
||||
|
||||
const primaryTargets: SessionWorktreeMenuTarget[] = [];
|
||||
const linkedTargets: SessionWorktreeMenuTarget[] = [];
|
||||
for (const target of targetsByPath.values()) {
|
||||
if (target.isPrimary) {
|
||||
primaryTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
linkedTargets.push(target);
|
||||
}
|
||||
|
||||
primaryTargets.sort((a, b) => a.metadata.path.localeCompare(b.metadata.path, undefined, { sensitivity: 'base' }));
|
||||
linkedTargets.sort(compareLinkedTargets);
|
||||
return [...primaryTargets, ...linkedTargets];
|
||||
};
|
||||
|
||||
export const startSessionWorktreeMenuLoad = (
|
||||
args: StartSessionWorktreeMenuLoadArgs,
|
||||
deps: StartSessionWorktreeMenuLoadDependencies,
|
||||
): StartSessionWorktreeMenuLoadResult => {
|
||||
const runtimeKey = deps.getRuntimeKey();
|
||||
const publishedWorktreesByProject = deps.getPublishedWorktreesByProject();
|
||||
const rawScope = ensureRawWorktreesByProjectScope({
|
||||
rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef,
|
||||
publishedWorktreesByProject,
|
||||
runtimeKey,
|
||||
});
|
||||
const projectById = args.projectId
|
||||
? deps.projects.find((candidate) => candidate.id === args.projectId) ?? null
|
||||
: null;
|
||||
const project = projectById ?? (args.sourceDirectory ? deps.resolveProject(args.sourceDirectory) : null);
|
||||
const normalizedProjectPath = normalizePath(project?.path ?? null);
|
||||
const cachedTargets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: normalizedProjectPath,
|
||||
discoveredWorktrees: normalizedProjectPath
|
||||
? (rawScope.worktreesByProject.get(normalizedProjectPath) ?? [])
|
||||
: [],
|
||||
sourceDirectory: args.sourceDirectory,
|
||||
currentWorktree: args.currentWorktree,
|
||||
projectRootBranch: deps.projectRootBranch,
|
||||
});
|
||||
|
||||
return {
|
||||
cachedTargets,
|
||||
refreshTargets: (async () => {
|
||||
if (!project || !normalizedProjectPath) {
|
||||
throw new Error('Unable to resolve worktree project');
|
||||
}
|
||||
|
||||
const refreshedWorktrees = await deps.listProjectWorktrees(project, { force: true });
|
||||
|
||||
if (deps.getRuntimeKey() !== runtimeKey) {
|
||||
throw new Error('Runtime changed during worktree refresh');
|
||||
}
|
||||
|
||||
const currentProjects = deps.getCurrentProjects();
|
||||
const currentProject = currentProjects.find((candidate) => candidate.id === project.id) ?? null;
|
||||
if (!currentProject || normalizePath(currentProject.path ?? null) !== normalizedProjectPath) {
|
||||
throw new Error('Project removed during worktree refresh');
|
||||
}
|
||||
|
||||
const currentRawScope = ensureRawWorktreesByProjectScope({
|
||||
rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef,
|
||||
publishedWorktreesByProject: deps.getPublishedWorktreesByProject(),
|
||||
runtimeKey,
|
||||
});
|
||||
const nextRawTopology = cloneWorktreesByProject(currentRawScope.worktreesByProject);
|
||||
const nextProjectWorktrees = [...refreshedWorktrees]
|
||||
.map((worktree) => cloneMetadata(worktree))
|
||||
.sort((a, b) => compareLinkedTargets(
|
||||
{ metadata: a, isPrimary: false, isCurrent: false },
|
||||
{ metadata: b, isPrimary: false, isCurrent: false },
|
||||
));
|
||||
|
||||
const refreshedRepositoryRoot = normalizePath(
|
||||
nextProjectWorktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory
|
||||
?? args.currentWorktree?.projectDirectory
|
||||
?? project.path,
|
||||
);
|
||||
const matchingProjectPaths = new Set<string>([normalizedProjectPath]);
|
||||
for (const [projectPath, worktrees] of nextRawTopology.entries()) {
|
||||
const repositoryRoot = normalizePath(
|
||||
worktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory ?? projectPath,
|
||||
);
|
||||
if (repositoryRoot && repositoryRoot === refreshedRepositoryRoot) {
|
||||
matchingProjectPaths.add(projectPath);
|
||||
}
|
||||
}
|
||||
for (const projectPath of matchingProjectPaths) {
|
||||
if (nextProjectWorktrees.length === 0) {
|
||||
nextRawTopology.delete(projectPath);
|
||||
continue;
|
||||
}
|
||||
nextRawTopology.set(projectPath, nextProjectWorktrees.map((worktree) => cloneMetadata(worktree)));
|
||||
}
|
||||
|
||||
markRawWorktreesByProjectMutation(deps.rawWorktreesByProjectRef, runtimeKey);
|
||||
deps.rawWorktreesByProjectRef.current = {
|
||||
runtimeKey,
|
||||
revision: deps.rawWorktreesByProjectRef.current.revision,
|
||||
worktreesByProject: nextRawTopology,
|
||||
};
|
||||
|
||||
const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(currentProjects, nextRawTopology);
|
||||
const allWorktrees = [...partitionedWorktreesByProject.values()].flat();
|
||||
deps.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), deps.now());
|
||||
|
||||
const latestPublishedWorktreesByProject = deps.getPublishedWorktreesByProject();
|
||||
if (!deps.worktreeMapsEqual(partitionedWorktreesByProject, latestPublishedWorktreesByProject)) {
|
||||
deps.publishTopology({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: partitionedWorktreesByProject,
|
||||
});
|
||||
}
|
||||
|
||||
return buildSessionWorktreeMenuTargets({
|
||||
projectPath: normalizedProjectPath,
|
||||
discoveredWorktrees: nextProjectWorktrees,
|
||||
sourceDirectory: args.sourceDirectory,
|
||||
currentWorktree: args.currentWorktree,
|
||||
projectRootBranch: deps.projectRootBranch,
|
||||
});
|
||||
})(),
|
||||
};
|
||||
};
|
||||
|
||||
export const commitDiscoveredRawWorktreesByProject = (args: {
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
|
||||
runtimeKey: string;
|
||||
capturedRevision: number;
|
||||
nextRawWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
publishedWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
partitionWorktreesByRegisteredProject: StartSessionWorktreeMenuLoadDependencies['partitionWorktreesByRegisteredProject'];
|
||||
projects: ReadonlyArray<Pick<ProjectRef, 'id' | 'path'>>;
|
||||
worktreeMapsEqual: StartSessionWorktreeMenuLoadDependencies['worktreeMapsEqual'];
|
||||
recordWorktreesSeen: StartSessionWorktreeMenuLoadDependencies['recordWorktreesSeen'];
|
||||
publishTopology: StartSessionWorktreeMenuLoadDependencies['publishTopology'];
|
||||
requestRediscovery: RequestRediscovery;
|
||||
now: () => number;
|
||||
}): boolean => {
|
||||
if (args.rawWorktreesByProjectRef.current.runtimeKey !== args.runtimeKey) {
|
||||
return false;
|
||||
}
|
||||
if (args.rawWorktreesByProjectRef.current.revision !== args.capturedRevision) {
|
||||
args.requestRediscovery();
|
||||
return false;
|
||||
}
|
||||
const partitionedWorktreesByProject = args.partitionWorktreesByRegisteredProject(args.projects, args.nextRawWorktreesByProject);
|
||||
const allWorktrees = [...partitionedWorktreesByProject.values()].flat();
|
||||
args.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), args.now());
|
||||
args.rawWorktreesByProjectRef.current = {
|
||||
runtimeKey: args.runtimeKey,
|
||||
revision: args.capturedRevision,
|
||||
worktreesByProject: new Map(args.nextRawWorktreesByProject),
|
||||
};
|
||||
if (!args.worktreeMapsEqual(partitionedWorktreesByProject, args.publishedWorktreesByProject)) {
|
||||
args.publishTopology({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: partitionedWorktreesByProject,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getSessionWorktreeMenuState = (args: {
|
||||
targets: ReadonlyArray<SessionWorktreeMenuTarget>;
|
||||
isRefreshing: boolean;
|
||||
loadFailed: boolean;
|
||||
}): SessionWorktreeMenuState => {
|
||||
return {
|
||||
refreshState: args.isRefreshing
|
||||
? 'loading'
|
||||
: (args.loadFailed && args.targets.length === 0 ? 'error' : null),
|
||||
showNewWorktreeAction: true,
|
||||
};
|
||||
};
|
||||
+267
-149
@@ -18,18 +18,17 @@ import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPinnedStore';
|
||||
import { isSessionPinned, useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||
import type { ChildSessionExport } from '@/lib/exportSession';
|
||||
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
|
||||
import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
|
||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
|
||||
import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from '../types';
|
||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
@@ -43,22 +42,33 @@ import { getSessionGoal } from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
|
||||
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { getChatsRootFromDirectory } from '@/lib/chatDirectories';
|
||||
import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
|
||||
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
|
||||
import { FusionIcon } from '@/components/icons/FusionIcon';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import {
|
||||
buildSessionTreeMoveMessages,
|
||||
requestSessionTreeMove,
|
||||
useIsSessionWorktreeMovePending,
|
||||
} from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
getSessionWorktreeMenuState,
|
||||
type SessionWorktreeMenuTarget,
|
||||
type StartSessionWorktreeMenuLoadResult,
|
||||
} from '../sessionWorktreeMenu';
|
||||
|
||||
type SecondaryMeta = {
|
||||
projectLabel?: string | null;
|
||||
branchLabel?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
export type SessionNodeItemProps = {
|
||||
node: SessionNode;
|
||||
depth?: number;
|
||||
groupDirectory?: string | null;
|
||||
@@ -78,7 +88,6 @@ type Props = {
|
||||
toggleParent: (expansionKey: string) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
|
||||
togglePinnedSession: (target: SessionPinnedTarget) => void;
|
||||
handleShareSession: (session: Session) => void;
|
||||
copiedSessionId: string | null;
|
||||
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
||||
@@ -86,27 +95,16 @@ type Props = {
|
||||
handleUnshareSession: (sessionId: string) => void;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
renamingFolderId: string | null;
|
||||
getFoldersForScope: (scopeKey: string) => Folder[];
|
||||
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
|
||||
removeSessionFromFolder: (scopeKey: string, sessionId: string) => void;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void;
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
|
||||
handleRestoreSession: (session: Session) => void;
|
||||
startSessionWorktreeMenuLoad: (args: {
|
||||
projectId: string | null;
|
||||
sourceDirectory: string | null;
|
||||
currentWorktree: WorktreeMetadata | null;
|
||||
}) => StartSessionWorktreeMenuLoadResult;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
renderSessionNode: (
|
||||
node: SessionNode,
|
||||
depth?: number,
|
||||
groupDirectory?: string | null,
|
||||
projectId?: string | null,
|
||||
archivedBucket?: boolean,
|
||||
secondaryMeta?: SecondaryMeta | null,
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
secondaryMeta?: SecondaryMeta | null;
|
||||
renderContext?: 'project' | 'recent';
|
||||
/**
|
||||
@@ -132,9 +130,14 @@ type Props = {
|
||||
* descendant; SessionNodeItem's recursive child render uses this lookup
|
||||
* to fetch the right key for each child it produces.
|
||||
*/
|
||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const areNodeWorktreeRenderSemanticsEqual = (prev: SessionNode, next: SessionNode): boolean => (
|
||||
normalizePath(prev.worktree?.path ?? null) === normalizePath(next.worktree?.path ?? null)
|
||||
&& prev.worktree?.branch === next.worktree?.branch
|
||||
);
|
||||
|
||||
// Shared row geometry: the gutter edge matches the zone-header band padding
|
||||
// (px-1.5 = 6px), the marker slot is icon-wide (14px) with a 6px gap, so row
|
||||
// text starts exactly where the zone-header label starts. Nested children
|
||||
@@ -146,7 +149,6 @@ const ROW_TEXT_LEFT_PX = ROW_GUTTER_LEFT_PX + 14 + 6;
|
||||
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
||||
|
||||
const holdSessionRowPosition = (target: HTMLElement): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const row = target.closest<HTMLElement>('[data-session-row]');
|
||||
const container = row?.closest<HTMLElement>('.overlay-scrollbar-container');
|
||||
if (!row || !container) return;
|
||||
@@ -224,7 +226,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -251,7 +253,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
||||
);
|
||||
});
|
||||
|
||||
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_session_node.render');
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
@@ -274,7 +276,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
toggleParent,
|
||||
handleSessionSelect,
|
||||
handleSessionDoubleClick,
|
||||
togglePinnedSession,
|
||||
handleShareSession,
|
||||
copiedSessionId,
|
||||
handleCopyShareUrl,
|
||||
@@ -282,24 +283,22 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
handleUnshareSession,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
renamingFolderId,
|
||||
getFoldersForScope,
|
||||
getSessionFolderId,
|
||||
removeSessionFromFolder,
|
||||
addSessionToFolder,
|
||||
createFolderAndStartRename,
|
||||
openContextPanelTab,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
startSessionWorktreeMenuLoad,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
renderSessionNode,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
childRenderExtrasFor,
|
||||
children,
|
||||
} = props;
|
||||
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
||||
const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope);
|
||||
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
|
||||
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
|
||||
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
||||
@@ -334,6 +333,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const editingIdRef = React.useRef(editingId);
|
||||
editingIdRef.current = editingId;
|
||||
const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null);
|
||||
const pendingFolderCreateRef = React.useRef(false);
|
||||
const handleSaveEditRef = React.useRef(handleSaveEdit);
|
||||
handleSaveEditRef.current = handleSaveEdit;
|
||||
const [renameDraft, setRenameDraft] = React.useState(editTitle);
|
||||
@@ -394,17 +394,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}, [prSummary, t]);
|
||||
const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
||||
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
const sessionDirectory = normalizePath(session.directory ?? null) ?? normalizePath(groupDirectory ?? null);
|
||||
// Multi-select scope: sessions are flat per project, so selection groups by
|
||||
// project (falling back to the directory when no project is known) — a
|
||||
// selection must survive mixing sessions from different worktrees.
|
||||
const selectionScopeKey = projectId ?? sessionDirectory ?? null;
|
||||
// Directory bootstrap is scheduled once at sidebar level. A row only needs
|
||||
// the lightweight store reference for scoped state and export actions.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sync = useSync();
|
||||
const loadExportRecords = useSessionMessageRecordsForExport();
|
||||
|
||||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||
const isRowSelected = useSessionMultiSelectStore(
|
||||
@@ -451,9 +446,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
// tick of the counter it only decides to mount.
|
||||
const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming);
|
||||
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
|
||||
const currentWorktreeMetadata = node.worktree ?? useSessionUIStore.getState().getWorktreeMetadata(session.id) ?? null;
|
||||
const [worktreeTargets, setWorktreeTargets] = React.useState<SessionWorktreeMenuTarget[]>([]);
|
||||
const [worktreeTargetsLoading, setWorktreeTargetsLoading] = React.useState(false);
|
||||
const [worktreeTargetsLoadFailed, setWorktreeTargetsLoadFailed] = React.useState(false);
|
||||
const worktreeSubmenuOpenRef = React.useRef(false);
|
||||
const worktreeLoadSequenceRef = React.useRef(0);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sessionGoal = getSessionGoal(resolvedSession);
|
||||
const sessionGoalGlyph = sessionGoal ? (
|
||||
// SAFETY: sessionGoalStatusLabelKey contains an i18n key for every SessionGoalStatus.
|
||||
<span
|
||||
className="inline-flex flex-shrink-0 items-center"
|
||||
title={t(sessionGoalStatusLabelKey[sessionGoal.status] as never)}
|
||||
@@ -470,7 +472,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
// expand the other. Matches the format of menuInstanceKey.
|
||||
const expansionKey = menuInstanceKey;
|
||||
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(expansionKey);
|
||||
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
|
||||
const questionBadgeSessionScopes = React.useMemo(
|
||||
() => selectQuestionBadgeSessionScopes(node, isExpanded, sessionDirectory),
|
||||
[isExpanded, node, sessionDirectory],
|
||||
);
|
||||
const pendingQuestionCount = useSessionQuestionCount(questionBadgeSessionScopes);
|
||||
const isSubtaskSession = Boolean(resolvedSession.parentID);
|
||||
const unseenCount = useSessionUnseenCount(session.id);
|
||||
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
||||
const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now();
|
||||
@@ -490,9 +497,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
for (const child of children) {
|
||||
try {
|
||||
if (!sessionDirectory) throw new Error('Session directory is required for export');
|
||||
await sync.loadCompleteHistory(child.session.id, sessionDirectory);
|
||||
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||||
const childRecords = await loadExportRecords({ directory: sessionDirectory, sessionID: child.session.id });
|
||||
if (!childRecords) throw new Error('Session runtime changed during export');
|
||||
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||||
// SAFETY: OpenCode session payloads may carry the optional agent label used by exports.
|
||||
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||||
const grandChildren = await collectChildExports(child.children);
|
||||
skipped += grandChildren.skipped;
|
||||
@@ -507,7 +515,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
}
|
||||
return { children: results, skipped };
|
||||
}, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]);
|
||||
}, [collectNodeDescendantIds, loadExportRecords, sessionDirectory, t]);
|
||||
|
||||
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
|
||||
if (count <= 0) return;
|
||||
@@ -522,14 +530,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sync.loadCompleteHistory(session.id, sessionDirectory);
|
||||
} catch {
|
||||
const records = await loadExportRecords({ directory: sessionDirectory, sessionID: session.id }).catch(() => null);
|
||||
if (!records) {
|
||||
toast.error(t('sessions.sidebar.session.export.failedLoadHistory'));
|
||||
return;
|
||||
}
|
||||
|
||||
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
||||
if (records.length === 0) {
|
||||
toast.error(t('sessions.sidebar.session.export.nothingToExport'));
|
||||
return;
|
||||
@@ -567,7 +572,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
downloadAsMarkdown(markdown, filename);
|
||||
toast.success(t('sessions.sidebar.session.export.success'));
|
||||
showSkippedSubtasksWarning(skippedSubtaskCount);
|
||||
}, [collectChildExports, directoryStore, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, sync, t]);
|
||||
}, [collectChildExports, loadExportRecords, node.children, resolvedSession.title, session.id, sessionDirectory, showSkippedSubtasksWarning, t]);
|
||||
const handleExportSession = React.useCallback(async () => {
|
||||
if (node.children.length > 0) {
|
||||
setExportIncludeSubtasks(true);
|
||||
@@ -597,7 +602,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
// its own rename form. A click inside ANY rename form for this session
|
||||
// must not count as "outside", or the sibling instance would save and
|
||||
// exit the rename mid-edit.
|
||||
const target = e.target as HTMLElement | null;
|
||||
// SAFETY: DOM mousedown targets are Nodes; closest is used only when the target is an Element.
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
const withinRenameForm = target?.closest?.(`[data-session-rename-form="${CSS.escape(session.id)}"]`);
|
||||
if (formRef.current && !withinRenameForm) {
|
||||
handleSaveEditRef.current(renameDraftRef.current);
|
||||
@@ -676,6 +682,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
const pendingPermissionCount = sessionPermissions.length;
|
||||
const pendingQuestionLabel = pendingQuestionCount === 1
|
||||
? t('sessions.sidebar.session.status.questionPendingSingle')
|
||||
: t('sessions.sidebar.session.status.questionPendingMany', { count: pendingQuestionCount });
|
||||
const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
|
||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||
// Both states are the same static dot; only the color separates "running"
|
||||
@@ -837,7 +846,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
void runtimeApis?.vscode?.executeCommand('openchamber.openSessionInEditor', session.id, sessionTitle);
|
||||
};
|
||||
|
||||
const handleRowSelect = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleRowSelect = (event?: React.MouseEvent<HTMLElement>) => {
|
||||
if (suppressNextSelectRef.current) {
|
||||
suppressNextSelectRef.current = false;
|
||||
return;
|
||||
@@ -846,12 +855,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (event?.shiftKey) {
|
||||
const rows = typeof document !== 'undefined'
|
||||
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
|
||||
: [];
|
||||
const rows = Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'));
|
||||
const orderedIds = rows
|
||||
.map((el) => el.getAttribute('data-session-row'))
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
.filter((id): id is string => id !== null && id.length > 0);
|
||||
const currentAnchor = useSessionMultiSelectStore.getState().anchorId;
|
||||
const descendantsById = new Map<string, string[]>();
|
||||
descendantsById.set(session.id, collectNodeDescendantIds(node));
|
||||
@@ -872,9 +879,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
// action menu), so nothing double-fires.
|
||||
const handleRowBackgroundClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.defaultPrevented) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
// SAFETY: React click targets are DOM EventTargets; closest is valid only for HTMLElements.
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
if (target?.closest('button, a, input, [role="menuitem"], [role="menu"]')) return;
|
||||
handleRowSelect(event as unknown as React.MouseEvent<HTMLButtonElement>);
|
||||
handleRowSelect(event);
|
||||
};
|
||||
|
||||
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -893,6 +901,41 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorktreeSubmenuOpenChange = (open: boolean) => {
|
||||
worktreeSubmenuOpenRef.current = open;
|
||||
worktreeLoadSequenceRef.current += 1;
|
||||
const loadSequence = worktreeLoadSequenceRef.current;
|
||||
if (!open) {
|
||||
setWorktreeTargetsLoading(false);
|
||||
setWorktreeTargetsLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
const load = startSessionWorktreeMenuLoad({
|
||||
projectId: projectId ?? null,
|
||||
sourceDirectory: sessionDirectory,
|
||||
currentWorktree: currentWorktreeMetadata,
|
||||
});
|
||||
setWorktreeTargets(load.cachedTargets);
|
||||
setWorktreeTargetsLoading(true);
|
||||
setWorktreeTargetsLoadFailed(false);
|
||||
void load.refreshTargets
|
||||
.then((freshTargets) => {
|
||||
if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) {
|
||||
return;
|
||||
}
|
||||
setWorktreeTargets(freshTargets);
|
||||
setWorktreeTargetsLoading(false);
|
||||
setWorktreeTargetsLoadFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) {
|
||||
return;
|
||||
}
|
||||
setWorktreeTargetsLoading(false);
|
||||
setWorktreeTargetsLoadFailed(true);
|
||||
});
|
||||
};
|
||||
|
||||
const renderSessionMenuItems = ({
|
||||
Item,
|
||||
Separator,
|
||||
@@ -949,38 +992,115 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="download" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.exportMarkdown')}
|
||||
</Item>
|
||||
{!isSubtaskSession && !archivedBucket && !isVSCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block">
|
||||
<Item
|
||||
disabled={!sessionDirectory || isStreaming || isMovingToWorktree}
|
||||
onClick={() => {
|
||||
if (!sessionDirectory || isStreaming || isMovingToWorktree) return;
|
||||
startSessionTreeWorktreeMove({
|
||||
root: resolvedSession,
|
||||
descendants: collectNodeDescendantSessions(node),
|
||||
sourceDirectory: sessionDirectory,
|
||||
successMessage: t('sessions.sidebar.session.moveToWorktree.success'),
|
||||
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'),
|
||||
});
|
||||
}}
|
||||
className="w-full [&>svg]:mr-1"
|
||||
>
|
||||
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.moveToWorktree')}
|
||||
</Item>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-72">
|
||||
{isMovingToWorktree
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
|
||||
: isStreaming
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
|
||||
: t('sessions.sidebar.session.moveToWorktree.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canShowSessionWorktreeMenu({ isSubtaskSession, archivedBucket: Boolean(archivedBucket), isVSCode, sessionDirectory }) ? (() => {
|
||||
const isWorktreeMenuDisabled = getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory,
|
||||
isStreaming,
|
||||
isMovingToWorktree,
|
||||
});
|
||||
const worktreeMenuState = getSessionWorktreeMenuState({
|
||||
targets: worktreeTargets,
|
||||
isRefreshing: worktreeTargetsLoading,
|
||||
loadFailed: worktreeTargetsLoadFailed,
|
||||
});
|
||||
return (
|
||||
<Sub onOpenChange={handleWorktreeSubmenuOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SubTrigger
|
||||
disabled={isWorktreeMenuDisabled}
|
||||
className="w-full [&>svg]:mr-1"
|
||||
data-session-worktree-submenu-trigger={session.id}
|
||||
>
|
||||
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.moveToWorktreeTargets')}
|
||||
</SubTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-72">
|
||||
{isMovingToWorktree
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
|
||||
: isStreaming
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
|
||||
: t('sessions.sidebar.session.moveToWorktree.tooltipTargets')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SubContent className="min-w-[220px]" data-session-worktree-submenu={session.id}>
|
||||
{worktreeTargets.map((target) => {
|
||||
const targetPath = normalizePath(target.metadata.path ?? null) ?? target.metadata.path;
|
||||
const itemLabel = target.isPrimary
|
||||
? t('sessions.sidebar.session.moveToWorktree.main')
|
||||
: (target.metadata.label || target.metadata.branch || target.metadata.name || target.metadata.path);
|
||||
const isDisabled = target.isCurrent || target.metadata.worktreeStatus !== 'ready';
|
||||
|
||||
return (
|
||||
<Item
|
||||
key={targetPath}
|
||||
disabled={isDisabled}
|
||||
title={target.metadata.path}
|
||||
data-session-worktree-target={targetPath}
|
||||
onClick={() => {
|
||||
if (isDisabled || !sessionDirectory) {
|
||||
return;
|
||||
}
|
||||
requestSessionTreeMove({
|
||||
kind: 'existing',
|
||||
root: resolvedSession,
|
||||
descendants: collectNodeDescendantSessions(node),
|
||||
sourceDirectory: sessionDirectory,
|
||||
destination: target.metadata,
|
||||
messages: buildSessionTreeMoveMessages(t, {
|
||||
success: 'sessions.sidebar.session.moveToWorktree.existingSuccess',
|
||||
failure: 'sessions.sidebar.session.moveToWorktree.existingFailed',
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1 truncate">
|
||||
<span className="truncate">{itemLabel}</span>
|
||||
{target.isCurrent ? <span className="sr-only">{t('sessions.sidebar.session.moveToWorktree.current')}</span> : null}
|
||||
</span>
|
||||
{target.isCurrent ? <Icon name="check" className="ml-2 h-3.5 w-3.5 flex-shrink-0 text-primary" aria-hidden="true" /> : null}
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
{worktreeMenuState.refreshState === 'loading' ? (
|
||||
<Item disabled data-session-worktree-refresh-state="loading" className="py-0.5 text-muted-foreground typography-micro">
|
||||
{t('sessions.sidebar.session.moveToWorktree.refreshing')}
|
||||
</Item>
|
||||
) : null}
|
||||
{worktreeMenuState.refreshState === 'error' ? (
|
||||
<Item disabled data-session-worktree-refresh-state="error" className="py-0.5 text-muted-foreground typography-micro">
|
||||
{t('sessions.sidebar.session.moveToWorktree.loadFailed')}
|
||||
</Item>
|
||||
) : null}
|
||||
<Separator />
|
||||
{worktreeMenuState.showNewWorktreeAction ? (
|
||||
<Item
|
||||
disabled={isWorktreeMenuDisabled}
|
||||
data-session-worktree-new-action="true"
|
||||
onClick={() => {
|
||||
if (isWorktreeMenuDisabled || !sessionDirectory) return;
|
||||
requestSessionTreeMove({
|
||||
kind: 'quick',
|
||||
root: resolvedSession,
|
||||
descendants: collectNodeDescendantSessions(node),
|
||||
sourceDirectory: sessionDirectory,
|
||||
messages: buildSessionTreeMoveMessages(t, {
|
||||
success: 'sessions.sidebar.session.moveToWorktree.success',
|
||||
failure: 'sessions.sidebar.session.moveToWorktree.failed',
|
||||
}),
|
||||
});
|
||||
}}
|
||||
className="[&>svg]:mr-1"
|
||||
>
|
||||
<Icon name="add" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.newWorktree')}
|
||||
</Item>
|
||||
) : null}
|
||||
</SubContent>
|
||||
</Sub>
|
||||
);
|
||||
})() : null}
|
||||
{isMultiRunLikeSession ? (
|
||||
<Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
|
||||
<FusionIcon className="mr-1 h-4 w-4" />
|
||||
@@ -1007,6 +1127,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
.forEach((worktree) => pushScope(worktree.path));
|
||||
}
|
||||
}
|
||||
pushScope(getChatsRootFromDirectory(sessionDirectory));
|
||||
pushScope(sessionDirectory);
|
||||
const folderEntries = scopes.flatMap((scope) =>
|
||||
getFoldersForScope(scope).map((folder) => ({ scope, folder })));
|
||||
@@ -1043,8 +1164,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
)}
|
||||
<Separator />
|
||||
<Item onClick={() => {
|
||||
const newFolder = createFolderAndStartRename(defaultScope);
|
||||
if (!newFolder) return;
|
||||
const newFolder = createFolderAndStartRename(defaultScope);
|
||||
if (!newFolder) return;
|
||||
pendingFolderCreateRef.current = true;
|
||||
if (currentEntry && currentEntry.scope !== defaultScope) {
|
||||
removeSessionFromFolder(currentEntry.scope, session.id);
|
||||
}
|
||||
@@ -1117,7 +1239,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
|
||||
const sessionMenuContent = (
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => {
|
||||
if (pendingFolderCreateRef.current) {
|
||||
pendingFolderCreateRef.current = false;
|
||||
return false;
|
||||
}
|
||||
return editingIdRef.current ? false : true;
|
||||
}}>
|
||||
{renderSessionMenuItems({
|
||||
Item: DropdownMenuItem,
|
||||
Separator: DropdownMenuSeparator,
|
||||
@@ -1133,9 +1261,14 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<ContextMenu.Positioner className="app-region-no-drag z-50">
|
||||
<ContextMenu.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}
|
||||
finalFocus={() => {
|
||||
if (pendingFolderCreateRef.current) {
|
||||
pendingFolderCreateRef.current = false;
|
||||
return false;
|
||||
}
|
||||
return editingIdRef.current ? false : true;
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevated)',
|
||||
color: 'var(--surface-elevated-foreground)',
|
||||
}}
|
||||
className={cn(dropdownMenuPopupClass, 'min-w-[180px]')}
|
||||
@@ -1304,6 +1437,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<span className="leading-none">{pendingPermissionCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{pendingQuestionCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0" title={pendingQuestionLabel} aria-label={pendingQuestionLabel}>
|
||||
<Icon name="question" className="h-3 w-3" />
|
||||
<span className="leading-none">{pendingQuestionCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -1420,27 +1559,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
{contextMenuContent}
|
||||
</ContextMenu.Root>
|
||||
</DraggableSessionRow>
|
||||
{hasChildren && isExpanded
|
||||
? node.children.map((child): React.ReactNode => {
|
||||
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
|
||||
? childRenderExtrasFor(child)
|
||||
: {
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: '',
|
||||
};
|
||||
return renderSessionNode(
|
||||
child,
|
||||
depth + 1,
|
||||
sessionDirectory ?? groupDirectory,
|
||||
projectId,
|
||||
archivedBucket,
|
||||
undefined,
|
||||
renderContext,
|
||||
childRenderExtras,
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{hasChildren && isExpanded ? children : null}
|
||||
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
|
||||
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
|
||||
<DialogHeader>
|
||||
@@ -1494,7 +1613,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
const getNodeSessionDirectory = (node: SessionNode): string | null => {
|
||||
return normalizePath((node.session as Session & { directory?: string | null }).directory ?? null);
|
||||
return normalizePath(node.session.directory ?? null);
|
||||
};
|
||||
|
||||
const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta | null): boolean => {
|
||||
@@ -1502,7 +1621,7 @@ const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta
|
||||
&& (prev?.branchLabel ?? null) === (next?.branchLabel ?? null);
|
||||
};
|
||||
|
||||
const getMenuSessionIdFromKey = (props: Props): string | null => {
|
||||
const getMenuSessionIdFromKey = (props: SessionNodeItemProps): string | null => {
|
||||
if (!props.openSidebarMenuKey) return null;
|
||||
const bucketTag = props.archivedBucket ? 'archived' : 'active';
|
||||
const prefix = `${props.renderContext ?? 'project'}:${bucketTag}:`;
|
||||
@@ -1511,12 +1630,12 @@ const getMenuSessionIdFromKey = (props: Props): string | null => {
|
||||
: null;
|
||||
};
|
||||
|
||||
const getRelevantMenuSessionId = (props: Props): string | null => {
|
||||
const getRelevantMenuSessionId = (props: SessionNodeItemProps): string | null => {
|
||||
return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props);
|
||||
};
|
||||
|
||||
const subtreeContainsSession = (
|
||||
props: Props,
|
||||
props: SessionNodeItemProps,
|
||||
sessionId: string | null,
|
||||
precomputed: Set<string>,
|
||||
): boolean => {
|
||||
@@ -1544,7 +1663,7 @@ const hasSetMembershipChangeInNode = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
const hasExpansionMembershipChange = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
|
||||
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
|
||||
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
|
||||
const nextBucketTag = next.archivedBucket ? 'archived' : 'active';
|
||||
@@ -1563,9 +1682,21 @@ const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
const areSessionRenderSemanticsEqual = (prev: Session, next: Session): boolean => (
|
||||
prev.id === next.id
|
||||
&& prev.title === next.title
|
||||
&& prev.directory === next.directory
|
||||
&& prev.parentID === next.parentID
|
||||
&& prev.share?.url === next.share?.url
|
||||
&& prev.time?.created === next.time?.created
|
||||
&& prev.time?.updated === next.time?.updated
|
||||
&& prev.time?.archived === next.time?.archived
|
||||
);
|
||||
|
||||
const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
|
||||
if (prev.node.session.id !== next.node.session.id) return false;
|
||||
if (prev.node.session !== next.node.session) return false;
|
||||
if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return false;
|
||||
if (!areNodeWorktreeRenderSemanticsEqual(prev.node, next.node)) return false;
|
||||
if (prev.depth !== next.depth) return false;
|
||||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||
if (prev.projectId !== next.projectId) return false;
|
||||
@@ -1628,14 +1759,6 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
if (prev.renamingFolderId !== next.renamingFolderId) {
|
||||
const prevMenuSessionId = getRelevantMenuSessionId(prev);
|
||||
const nextMenuSessionId = getRelevantMenuSessionId(next);
|
||||
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return prev.setEditingId === next.setEditingId
|
||||
&& prev.setEditTitle === next.setEditTitle
|
||||
&& prev.handleSaveEdit === next.handleSaveEdit
|
||||
@@ -1643,21 +1766,16 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
&& prev.toggleParent === next.toggleParent
|
||||
&& prev.handleSessionSelect === next.handleSessionSelect
|
||||
&& prev.handleSessionDoubleClick === next.handleSessionDoubleClick
|
||||
&& prev.togglePinnedSession === next.togglePinnedSession
|
||||
&& prev.handleShareSession === next.handleShareSession
|
||||
&& prev.handleCopyShareUrl === next.handleCopyShareUrl
|
||||
&& prev.handleCopySessionId === next.handleCopySessionId
|
||||
&& prev.handleUnshareSession === next.handleUnshareSession
|
||||
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
|
||||
&& prev.getFoldersForScope === next.getFoldersForScope
|
||||
&& prev.getSessionFolderId === next.getSessionFolderId
|
||||
&& prev.removeSessionFromFolder === next.removeSessionFromFolder
|
||||
&& prev.addSessionToFolder === next.addSessionToFolder
|
||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
||||
&& prev.openContextPanelTab === next.openContextPanelTab
|
||||
&& prev.handleDeleteSession === next.handleDeleteSession
|
||||
&& prev.handleRestoreSession === next.handleRestoreSession
|
||||
&& prev.renderSessionNode === next.renderSessionNode;
|
||||
&& prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad
|
||||
&& prev.children === next.children;
|
||||
};
|
||||
|
||||
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionNodeItemProps } from './SessionNodeItem';
|
||||
import type { SessionTreeItemProps } from './SessionTreeItem';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
|
||||
const renderedRows: SessionNodeItemProps[] = [];
|
||||
|
||||
mock.module('./SessionNodeItem', () => ({
|
||||
SessionNodeItem: (props: SessionNodeItemProps) => {
|
||||
renderedRows.push(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('./hooks/useSessionActions', () => ({
|
||||
useSessionActions: (args: {
|
||||
setEditingId: (id: string | null) => void;
|
||||
setEditTitle: (title: string) => void;
|
||||
}) => ({
|
||||
copiedSessionId: null,
|
||||
handleSaveEdit: () => undefined,
|
||||
handleCancelEdit: () => undefined,
|
||||
handleSessionSelect: () => undefined,
|
||||
handleSessionDoubleClick: (id: string, title: string) => {
|
||||
args.setEditingId(id);
|
||||
args.setEditTitle(title);
|
||||
},
|
||||
handleShareSession: () => undefined,
|
||||
handleCopyShareUrl: () => undefined,
|
||||
handleCopySessionId: () => undefined,
|
||||
handleUnshareSession: () => undefined,
|
||||
handleDeleteSession: () => undefined,
|
||||
handleRestoreSession: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { SessionTreeItem } = await import('./SessionTreeItem');
|
||||
|
||||
const noopStartSessionWorktreeMenuLoad: SessionTreeItemProps['startSessionWorktreeMenuLoad'] = () => ({
|
||||
cachedTargets: [],
|
||||
refreshTargets: Promise.resolve([]),
|
||||
});
|
||||
|
||||
const session = (id: string): Session => ({
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
title: 'Shared title',
|
||||
version: '1',
|
||||
directory: '/workspace',
|
||||
time: { created: 1, updated: 1 },
|
||||
});
|
||||
|
||||
describe('SessionTreeItem public behavior', () => {
|
||||
test('coordinates duplicate project and Recent rows through their shared visible-list state', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const sharedSession = session('same-session');
|
||||
const rowNode = { session: sharedSession, children: [], worktree: null };
|
||||
const noop = () => undefined;
|
||||
|
||||
const Harness = () => {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [menuKey, setMenuKey] = React.useState<string | null>(null);
|
||||
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
||||
const rows = [
|
||||
{ renderContext: 'project' as const, groupDirectory: '/workspace' },
|
||||
{ renderContext: 'recent' as const, groupDirectory: '/workspace' },
|
||||
];
|
||||
return <>{rows.map((context) => <SessionTreeItem
|
||||
key={context.renderContext}
|
||||
node={rowNode}
|
||||
pinnedSessionIds={new Set()}
|
||||
expandedParents={new Set()}
|
||||
hasSessionSearchQuery={false}
|
||||
normalizedSessionSearchQuery=""
|
||||
notifyOnSubtasks={false}
|
||||
editingId={editingId}
|
||||
setEditingId={setEditingId}
|
||||
editTitle={editTitle}
|
||||
setEditTitle={setEditTitle}
|
||||
toggleParent={noop}
|
||||
copiedSessionId={copiedSessionId}
|
||||
openSidebarMenuKey={menuKey}
|
||||
setOpenSidebarMenuKey={setMenuKey}
|
||||
allowReselect={false}
|
||||
isSessionSearchOpen={false}
|
||||
sessionSearchQuery=""
|
||||
setSessionSearchQuery={noop}
|
||||
setIsSessionSearchOpen={noop}
|
||||
deleteSessionConfirm={null}
|
||||
setDeleteSessionConfirm={noop}
|
||||
startFolderRename={noop}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={noopStartSessionWorktreeMenuLoad}
|
||||
mobileVariant={false}
|
||||
alwaysShowActions={false}
|
||||
{...context}
|
||||
/>)}</>;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
|
||||
expect(renderedRows).toHaveLength(2);
|
||||
|
||||
await act(async () => renderedRows[0]?.handleSessionDoubleClick(sharedSession.id, sharedSession.title));
|
||||
expect(renderedRows).toHaveLength(4);
|
||||
expect(renderedRows.slice(-2).map((row) => [row.editingId, row.editTitle]))
|
||||
.toEqual([[sharedSession.id, sharedSession.title], [sharedSession.id, sharedSession.title]]);
|
||||
|
||||
await act(async () => renderedRows[3]?.setOpenSidebarMenuKey('recent:active:same-session'));
|
||||
expect(renderedRows).toHaveLength(6);
|
||||
expect(renderedRows.slice(-2).map((row) => row.openSidebarMenuKey))
|
||||
.toEqual(['recent:active:same-session', 'recent:active:same-session']);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
renderedRows.length = 0;
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import React from 'react';
|
||||
import { SessionNodeItem } from './SessionNodeItem';
|
||||
import type { SessionNodeItemProps } from './SessionNodeItem';
|
||||
import type { SessionNode } from '../types';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import { useSessionActions, type DeleteSessionConfirmState } from './useSessionActions';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { SessionDeleteConfirmDialog } from '../shell/ConfirmDialogs';
|
||||
|
||||
type Context = {
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
archivedBucket?: boolean;
|
||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null;
|
||||
renderContext?: 'project' | 'recent';
|
||||
};
|
||||
|
||||
type SessionTreeItemRenderProps = Context & Pick<SessionNodeItemProps,
|
||||
| 'expandedParents'
|
||||
| 'hasSessionSearchQuery'
|
||||
| 'normalizedSessionSearchQuery'
|
||||
| 'notifyOnSubtasks'
|
||||
| 'editingId'
|
||||
| 'editTitle'
|
||||
| 'copiedSessionId'
|
||||
| 'openSidebarMenuKey'
|
||||
| 'mobileVariant'
|
||||
| 'alwaysShowActions'
|
||||
> & {
|
||||
node: SessionNode;
|
||||
pinnedSessionIds: Set<string>;
|
||||
depth?: number;
|
||||
renderExtras?: SessionNodeRenderExtras;
|
||||
};
|
||||
|
||||
export type SessionTreeItemProps = SessionTreeItemRenderProps & Pick<SessionNodeItemProps,
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
> & {
|
||||
allowReselect: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
isSessionSearchOpen: boolean;
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
deleteSessionConfirm: DeleteSessionConfirmState;
|
||||
setDeleteSessionConfirm: (value: DeleteSessionConfirmState) => void;
|
||||
startFolderRename: (scopeKey: string, folder: { id: string; name: string }) => void;
|
||||
setCopiedSessionId: (sessionId: string | null) => void;
|
||||
};
|
||||
|
||||
const EMPTY_SUBTREE_CONTAINS_EDITING: Set<string> = new Set();
|
||||
|
||||
// This is the recursive ownership boundary. Structural parents pass identity
|
||||
// and stable UI actions; the row itself remains the leaf subscriber for live UI state.
|
||||
export function SessionTreeItem({
|
||||
node,
|
||||
depth = 0,
|
||||
groupDirectory,
|
||||
projectId,
|
||||
archivedBucket = false,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
renderExtras,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
notifyOnSubtasks,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
toggleParent,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
allowReselect,
|
||||
onSessionSelected,
|
||||
isSessionSearchOpen,
|
||||
sessionSearchQuery,
|
||||
setSessionSearchQuery,
|
||||
setIsSessionSearchOpen,
|
||||
deleteSessionConfirm,
|
||||
setDeleteSessionConfirm,
|
||||
startFolderRename,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
startSessionWorktreeMenuLoad,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
}: SessionTreeItemProps): React.ReactNode {
|
||||
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||
const descendantIds = React.useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
const visit = (current: SessionNode) => current.children.forEach((child) => {
|
||||
ids.push(child.session.id);
|
||||
visit(child);
|
||||
});
|
||||
visit(node);
|
||||
return ids;
|
||||
}, [node]);
|
||||
const createFolderAndStartRename = React.useCallback((scopeKey: string, parentId?: string | null) => {
|
||||
if (!scopeKey) return null;
|
||||
if (parentId && useSessionFoldersStore.getState().collapsedFolderIds.has(parentId)) toggleFolderCollapse(parentId);
|
||||
const folder = createFolder(scopeKey, 'New folder', parentId);
|
||||
startFolderRename(scopeKey, folder);
|
||||
return folder;
|
||||
}, [createFolder, startFolderRename, toggleFolderCollapse]);
|
||||
const sessionActions = useSessionActions({
|
||||
mobileVariant,
|
||||
allowReselect,
|
||||
onSessionSelected,
|
||||
isSessionSearchOpen,
|
||||
sessionSearchQuery,
|
||||
setSessionSearchQuery,
|
||||
setIsSessionSearchOpen,
|
||||
descendantIds,
|
||||
showDeletionDialog,
|
||||
setDeleteSessionConfirm,
|
||||
deleteSessionConfirm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
});
|
||||
const childRenderExtrasFor = renderExtras?.childRenderExtrasFor;
|
||||
const childContext: Context = {
|
||||
groupDirectory: node.session.directory ?? groupDirectory,
|
||||
projectId,
|
||||
archivedBucket,
|
||||
renderContext,
|
||||
};
|
||||
return <>
|
||||
<SessionNodeItem
|
||||
expandedParents={expandedParents}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
editingId={editingId}
|
||||
setEditingId={setEditingId}
|
||||
editTitle={editTitle}
|
||||
setEditTitle={setEditTitle}
|
||||
handleSaveEdit={sessionActions.handleSaveEdit}
|
||||
handleCancelEdit={sessionActions.handleCancelEdit}
|
||||
toggleParent={toggleParent}
|
||||
handleSessionSelect={sessionActions.handleSessionSelect}
|
||||
handleSessionDoubleClick={sessionActions.handleSessionDoubleClick}
|
||||
handleShareSession={sessionActions.handleShareSession}
|
||||
copiedSessionId={copiedSessionId}
|
||||
handleCopyShareUrl={sessionActions.handleCopyShareUrl}
|
||||
handleCopySessionId={sessionActions.handleCopySessionId}
|
||||
handleUnshareSession={sessionActions.handleUnshareSession}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
createFolderAndStartRename={createFolderAndStartRename}
|
||||
handleDeleteSession={sessionActions.handleDeleteSession}
|
||||
handleRestoreSession={sessionActions.handleRestoreSession}
|
||||
startSessionWorktreeMenuLoad={startSessionWorktreeMenuLoad}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
node={node}
|
||||
depth={depth}
|
||||
groupDirectory={groupDirectory}
|
||||
projectId={projectId}
|
||||
archivedBucket={archivedBucket}
|
||||
secondaryMeta={secondaryMeta}
|
||||
renderContext={renderContext}
|
||||
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_CONTAINS_EDITING}
|
||||
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
|
||||
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
|
||||
>
|
||||
{node.children.map((child) => (
|
||||
<SessionTreeItem
|
||||
key={child.session.id}
|
||||
node={child}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
editingId={editingId}
|
||||
setEditingId={setEditingId}
|
||||
editTitle={editTitle}
|
||||
copiedSessionId={copiedSessionId}
|
||||
setEditTitle={setEditTitle}
|
||||
toggleParent={toggleParent}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
allowReselect={allowReselect}
|
||||
onSessionSelected={onSessionSelected}
|
||||
isSessionSearchOpen={isSessionSearchOpen}
|
||||
sessionSearchQuery={sessionSearchQuery}
|
||||
setSessionSearchQuery={setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||
startFolderRename={startFolderRename}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={startSessionWorktreeMenuLoad}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
depth={depth + 1}
|
||||
{...childContext}
|
||||
renderExtras={childRenderExtrasFor?.(child)}
|
||||
/>
|
||||
))}
|
||||
</SessionNodeItem>
|
||||
{deleteSessionConfirm?.session.id === node.session.id ? <SessionDeleteConfirmDialog
|
||||
value={deleteSessionConfirm}
|
||||
setValue={setDeleteSessionConfirm}
|
||||
showDeletionDialog={showDeletionDialog}
|
||||
setShowDeletionDialog={setShowDeletionDialog}
|
||||
onConfirm={sessionActions.confirmDeleteSession}
|
||||
/> : null}
|
||||
</>;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { replaceGlobalSessionStatusById } from '@/sync/global-session-status';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { useCollapsedSessionActivityState } from './collapsedActivityState';
|
||||
import type { SessionNode } from '../types';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
// SAFETY: the fixture supplies the minimal SDK identity used by the selector.
|
||||
const node = (id: string): SessionNode => ({ session: { id } as Session, children: [], worktree: null });
|
||||
|
||||
describe('collapsed activity scalar selector', () => {
|
||||
test('does not rerender for unrelated updates and rerenders for relevant scalar changes', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
replaceGlobalSessionStatusById(new Map());
|
||||
useNotificationStore.setState({
|
||||
list: [],
|
||||
index: { session: { unseenCount: {}, unseenHasError: {} }, project: { unseenCount: {}, unseenHasError: {} } },
|
||||
});
|
||||
type ActivityCapture = { renders: number; state: string | null };
|
||||
const capture: ActivityCapture = { renders: 0, state: null };
|
||||
const Harness = () => {
|
||||
capture.renders += 1;
|
||||
capture.state = useCollapsedSessionActivityState({ nodes: [node('relevant')], includeUnreadSubtasks: true });
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
const initialRenders = capture.renders;
|
||||
await act(async () => replaceGlobalSessionStatusById(new Map([['unrelated', { status: { type: 'busy' }, directory: '/other' }]])));
|
||||
await act(async () => useNotificationStore.getState().append({
|
||||
type: 'turn-complete', session: 'unrelated', time: Date.now(), viewed: false,
|
||||
}));
|
||||
expect(capture.renders).toBe(initialRenders);
|
||||
|
||||
await act(async () => useNotificationStore.getState().append({
|
||||
type: 'turn-complete', session: 'relevant', time: Date.now(), viewed: false,
|
||||
}));
|
||||
expect(capture.state).toBe('unread');
|
||||
const unreadRenders = capture.renders;
|
||||
await act(async () => replaceGlobalSessionStatusById(new Map([['relevant', { status: { type: 'busy' }, directory: '/workspace' }]])));
|
||||
expect(capture.state).toBe('active');
|
||||
expect(capture.renders).toBe(unreadRenders + 1);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
replaceGlobalSessionStatusById(new Map());
|
||||
useNotificationStore.setState({
|
||||
list: [],
|
||||
index: { session: { unseenCount: {}, unseenHasError: {} }, project: { unseenCount: {}, unseenHasError: {} } },
|
||||
});
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getSessionNodesActivityState } from './collapsedActivityState';
|
||||
import type { SessionNode } from './types';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
// SAFETY: the fixture supplies the minimal SDK identity fields used by the activity projection.
|
||||
const node = (id: string, parentID?: string, children: SessionNode[] = []): SessionNode => ({
|
||||
session: { id, parentID } as Session,
|
||||
children,
|
||||
+14
-1
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CollapsedActivityState } from './collapsedActivityState';
|
||||
import type { SessionNode } from '../types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useCollapsedSessionActivityState, type CollapsedActivityState } from './collapsedActivityState';
|
||||
|
||||
export function CollapsedActivityIndicator({
|
||||
state,
|
||||
@@ -28,3 +30,14 @@ export function CollapsedActivityIndicator({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const CollapsedSessionActivityIndicator: React.FC<{ nodes: SessionNode[]; includeUnreadSubtasks: boolean }> = ({ nodes, includeUnreadSubtasks }) => {
|
||||
const { t } = useI18n();
|
||||
const resolved = useCollapsedSessionActivityState({ nodes, includeUnreadSubtasks });
|
||||
if (!resolved) return null;
|
||||
return <CollapsedActivityIndicator
|
||||
state={resolved}
|
||||
activeLabel={t('sessions.sidebar.session.status.active')}
|
||||
unreadLabel={t('sessions.sidebar.session.status.unread')}
|
||||
/>;
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import React from 'react';
|
||||
import type { SessionNode } from '../types';
|
||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
|
||||
export type CollapsedActivityState = 'active' | 'unread' | null;
|
||||
|
||||
const mergeCollapsedActivityStates = (
|
||||
current: CollapsedActivityState,
|
||||
next: CollapsedActivityState,
|
||||
): CollapsedActivityState => {
|
||||
if (current === 'active' || next === 'active') return 'active';
|
||||
if (current === 'unread' || next === 'unread') return 'unread';
|
||||
return null;
|
||||
};
|
||||
|
||||
const getSessionNodeActivityState = (
|
||||
node: SessionNode,
|
||||
activeSessionIds: Set<string>,
|
||||
unreadSessionIds: Set<string>,
|
||||
includeUnreadSubtasks: boolean,
|
||||
): CollapsedActivityState => {
|
||||
if (activeSessionIds.has(node.session.id)) return 'active';
|
||||
|
||||
let state: CollapsedActivityState = null;
|
||||
// SAFETY: SessionNode sessions are SDK Session records; parentID is the optional hierarchy field.
|
||||
const isSubtask = Boolean((node.session as Session & { parentID?: string | null }).parentID);
|
||||
if (unreadSessionIds.has(node.session.id) && (includeUnreadSubtasks || !isSubtask)) state = 'unread';
|
||||
|
||||
for (const child of node.children) {
|
||||
state = mergeCollapsedActivityStates(
|
||||
state,
|
||||
getSessionNodeActivityState(child, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||
);
|
||||
if (state === 'active') return state;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export const getSessionNodesActivityState = (
|
||||
nodes: SessionNode[],
|
||||
activeSessionIds: Set<string>,
|
||||
unreadSessionIds: Set<string>,
|
||||
includeUnreadSubtasks: boolean,
|
||||
): CollapsedActivityState => {
|
||||
let state: CollapsedActivityState = null;
|
||||
for (const node of nodes) {
|
||||
state = mergeCollapsedActivityStates(
|
||||
state,
|
||||
getSessionNodeActivityState(node, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||
);
|
||||
if (state === 'active') return state;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
type SessionActivityProps = {
|
||||
nodes: SessionNode[];
|
||||
includeUnreadSubtasks: boolean;
|
||||
};
|
||||
|
||||
const collectActivityIds = (nodes: SessionNode[], includeUnreadSubtasks: boolean) => {
|
||||
const active = new Set<string>();
|
||||
const unread = new Set<string>();
|
||||
const visit = (node: SessionNode, isSubtask: boolean): void => {
|
||||
active.add(node.session.id);
|
||||
if (!isSubtask || includeUnreadSubtasks) unread.add(node.session.id);
|
||||
node.children.forEach((child) => visit(child, true));
|
||||
};
|
||||
nodes.forEach((node) => visit(node, false));
|
||||
return { active, unread };
|
||||
};
|
||||
|
||||
export const useCollapsedSessionActivityState = ({
|
||||
nodes,
|
||||
includeUnreadSubtasks,
|
||||
enabled = true,
|
||||
}: SessionActivityProps & { enabled?: boolean }): CollapsedActivityState => {
|
||||
const ids = React.useMemo(() => collectActivityIds(nodes, includeUnreadSubtasks), [includeUnreadSubtasks, nodes]);
|
||||
const active = useGlobalSessionStatusStore(React.useCallback((state): CollapsedActivityState => {
|
||||
if (!enabled) return null;
|
||||
for (const sessionId of ids.active) {
|
||||
const status = state.statusById.get(sessionId)?.status.type;
|
||||
if (status === 'busy' || status === 'retry') return 'active';
|
||||
}
|
||||
return null;
|
||||
}, [enabled, ids.active]));
|
||||
const unread = useNotificationStore(React.useCallback((state): CollapsedActivityState => {
|
||||
if (!enabled) return null;
|
||||
for (const sessionId of ids.unread) {
|
||||
if ((state.index.session.unseenCount[sessionId] ?? 0) > 0) return 'unread';
|
||||
}
|
||||
return null;
|
||||
}, [enabled, ids.unread]));
|
||||
return active ?? unread;
|
||||
};
|
||||
+90
-2
@@ -2,8 +2,15 @@ import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
import {
|
||||
computeNodeStructureKey,
|
||||
canShowSessionWorktreeMenu,
|
||||
getSessionWorktreeMenuDisabled,
|
||||
nodeHasPinnedMembershipChange,
|
||||
selectFolderRootNodes,
|
||||
selectQuestionBadgeSessionScopes,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
const session = (id: string, title: string): Session => ({
|
||||
id,
|
||||
@@ -32,6 +39,41 @@ describe('computeNodeStructureKey', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectQuestionBadgeSessionScopes', () => {
|
||||
const withDirectory = (node: SessionNode, directory: string | null): SessionNode => ({
|
||||
...node,
|
||||
session: { ...node.session, directory } as Session,
|
||||
});
|
||||
|
||||
test('rolls up the hidden subtree by owning directory when a parent is collapsed', () => {
|
||||
const grandchild = withDirectory({ session: session('grandchild', 'Grandchild'), children: [], worktree: null }, '/worktrees/feature');
|
||||
const child = withDirectory({ session: session('child', 'Child'), children: [grandchild], worktree: null }, '/worktrees/feature');
|
||||
const root = withDirectory({ session: session('root', 'Root'), children: [child], worktree: null }, '/repo');
|
||||
|
||||
expect(selectQuestionBadgeSessionScopes(root, false, '/repo')).toEqual([
|
||||
{ directory: '/repo', sessionIDs: ['root'] },
|
||||
{ directory: '/worktrees/feature', sessionIDs: ['child', 'grandchild'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps expanded rows accurate to their own session only', () => {
|
||||
const child = withDirectory({ session: session('child', 'Child'), children: [], worktree: null }, '/worktrees/feature');
|
||||
const root = withDirectory({ session: session('root', 'Root'), children: [child], worktree: null }, '/repo');
|
||||
|
||||
expect(selectQuestionBadgeSessionScopes(root, true, '/repo')).toEqual([
|
||||
{ directory: '/repo', sessionIDs: ['root'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to the group directory when the session has none', () => {
|
||||
const root: SessionNode = { session: session('root', 'Root'), children: [], worktree: null };
|
||||
|
||||
expect(selectQuestionBadgeSessionScopes(root, false, '/fallback')).toEqual([
|
||||
{ directory: '/fallback', sessionIDs: ['root'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeHasPinnedMembershipChange', () => {
|
||||
test('detects composite pin changes using the group directory fallback', () => {
|
||||
const node: SessionNode = {
|
||||
@@ -123,3 +165,49 @@ describe('selectFolderRootNodes', () => {
|
||||
expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionWorktreeMenuDisabled', () => {
|
||||
test('shares the parent trigger disabled contract with the new worktree action', () => {
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: '/repo-feature',
|
||||
isStreaming: false,
|
||||
isMovingToWorktree: false,
|
||||
})).toBe(false);
|
||||
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: null,
|
||||
isStreaming: false,
|
||||
isMovingToWorktree: false,
|
||||
})).toBe(true);
|
||||
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: '/repo-feature',
|
||||
isStreaming: true,
|
||||
isMovingToWorktree: false,
|
||||
})).toBe(true);
|
||||
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: '/repo-feature',
|
||||
isStreaming: false,
|
||||
isMovingToWorktree: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowSessionWorktreeMenu', () => {
|
||||
test('hides worktree moves for managed Chat directories', () => {
|
||||
expect(canShowSessionWorktreeMenu({
|
||||
isSubtaskSession: false,
|
||||
archivedBucket: false,
|
||||
isVSCode: false,
|
||||
sessionDirectory: '/home/test/.config/openchamber/chats/2026-08-25/session-1',
|
||||
})).toBe(false);
|
||||
|
||||
expect(canShowSessionWorktreeMenu({
|
||||
isSubtaskSession: false,
|
||||
archivedBucket: false,
|
||||
isVSCode: false,
|
||||
sessionDirectory: '/repo',
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
+176
-2
@@ -1,6 +1,10 @@
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||
import type { SessionNode } from './types';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
/**
|
||||
* Per-row render extras precomputed once per group render and threaded down to
|
||||
@@ -70,6 +74,66 @@ export const nodeContainsSessionId = (node: SessionNode, sessionId: string | nul
|
||||
return false;
|
||||
};
|
||||
|
||||
export type QuestionBadgeSessionScope = {
|
||||
directory: string;
|
||||
sessionIDs: string[];
|
||||
};
|
||||
|
||||
export const canShowSessionWorktreeMenu = ({
|
||||
isSubtaskSession,
|
||||
archivedBucket,
|
||||
isVSCode,
|
||||
sessionDirectory,
|
||||
}: {
|
||||
isSubtaskSession: boolean;
|
||||
archivedBucket: boolean;
|
||||
isVSCode: boolean;
|
||||
sessionDirectory: string | null;
|
||||
}): boolean => !isSubtaskSession
|
||||
&& !archivedBucket
|
||||
&& !isVSCode
|
||||
&& !isChatDirectoryPath(sessionDirectory);
|
||||
|
||||
export const getSessionWorktreeMenuDisabled = ({
|
||||
sessionDirectory,
|
||||
isStreaming,
|
||||
isMovingToWorktree,
|
||||
}: {
|
||||
sessionDirectory: string | null;
|
||||
isStreaming: boolean;
|
||||
isMovingToWorktree: boolean;
|
||||
}): boolean => !sessionDirectory || isStreaming || isMovingToWorktree;
|
||||
|
||||
/**
|
||||
* Choose which (directory, sessionIDs) scopes a sidebar row's pending-question
|
||||
* badge should count. An expanded row counts only its own session; a collapsed
|
||||
* parent row additionally rolls up the hidden descendants of its subtree,
|
||||
* grouped by the directory store each descendant actually lives in, so badges
|
||||
* stay correct for worktree/subtask sessions without bootstrapping their
|
||||
* directory stores.
|
||||
*/
|
||||
export const selectQuestionBadgeSessionScopes = (
|
||||
node: SessionNode,
|
||||
isExpanded: boolean,
|
||||
fallbackDirectory: string | null,
|
||||
): QuestionBadgeSessionScope[] => {
|
||||
const sessionIDsByDirectory = new Map<string, string[]>();
|
||||
const visit = (current: SessionNode): void => {
|
||||
const directory = resolveGlobalSessionDirectory(current.session)
|
||||
?? normalizePath(current.worktree?.path)
|
||||
?? fallbackDirectory;
|
||||
if (directory) {
|
||||
const sessionIDs = sessionIDsByDirectory.get(directory) ?? [];
|
||||
sessionIDs.push(current.session.id);
|
||||
sessionIDsByDirectory.set(directory, sessionIDs);
|
||||
}
|
||||
if (current === node && isExpanded) return;
|
||||
for (const child of current.children) visit(child);
|
||||
};
|
||||
visit(node);
|
||||
return [...sessionIDsByDirectory].map(([directory, sessionIDs]) => ({ directory, sessionIDs }));
|
||||
};
|
||||
|
||||
export const selectFolderRootNodes = (
|
||||
sessionIds: string[],
|
||||
nodeBySessionId: ReadonlyMap<string, SessionNode>,
|
||||
@@ -90,7 +154,117 @@ export const selectFolderRootNodes = (
|
||||
parentID = (parentNode?.session as (SessionNode['session'] & { parentID?: string | null }) | undefined)?.parentID ?? null;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
type FolderHierarchyEntry = {
|
||||
id: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Preserve stored folder order while projecting every disconnected or cyclic
|
||||
* component from a deterministic root. The persisted parent links stay as-is.
|
||||
*/
|
||||
export const normalizeFolderRoots = <T extends FolderHierarchyEntry>(folders: readonly T[]): T[] => {
|
||||
const folderById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
const childrenByParentId = new Map<string, T[]>();
|
||||
for (const folder of folders) {
|
||||
if (!folder.parentId || !folderById.has(folder.parentId)) continue;
|
||||
const children = childrenByParentId.get(folder.parentId) ?? [];
|
||||
children.push(folder);
|
||||
childrenByParentId.set(folder.parentId, children);
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const roots: T[] = [];
|
||||
const addRoot = (folder: T): void => {
|
||||
if (visited.has(folder.id)) return;
|
||||
roots.push(folder);
|
||||
const stack = [folder.id];
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop();
|
||||
if (!id || visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
for (const child of childrenByParentId.get(id) ?? []) stack.push(child.id);
|
||||
}
|
||||
};
|
||||
|
||||
folders.forEach((folder) => {
|
||||
if (!folder.parentId || !folderById.has(folder.parentId)) addRoot(folder);
|
||||
});
|
||||
folders.forEach(addRoot);
|
||||
return roots;
|
||||
};
|
||||
|
||||
type FolderProjectionEntry = FolderHierarchyEntry & {
|
||||
name: string;
|
||||
nodeCount: number;
|
||||
};
|
||||
|
||||
type FolderProjectionOptions = {
|
||||
archivedBucket: boolean;
|
||||
searchQuery: string;
|
||||
};
|
||||
|
||||
export const selectFolderIdsForProjection = (
|
||||
entries: readonly FolderProjectionEntry[],
|
||||
options: FolderProjectionOptions,
|
||||
): Set<string> => {
|
||||
const entryById = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
const childIdsByParentId = new Map<string, string[]>();
|
||||
const malformedIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.parentId && !entryById.has(entry.parentId)) {
|
||||
malformedIds.add(entry.id);
|
||||
continue;
|
||||
}
|
||||
if (entry.parentId) {
|
||||
const children = childIdsByParentId.get(entry.parentId) ?? [];
|
||||
children.push(entry.id);
|
||||
childIdsByParentId.set(entry.parentId, children);
|
||||
}
|
||||
|
||||
const visitedParents = new Set<string>();
|
||||
let currentId: string | null | undefined = entry.id;
|
||||
while (currentId) {
|
||||
if (visitedParents.has(currentId)) {
|
||||
malformedIds.add(entry.id);
|
||||
break;
|
||||
}
|
||||
visitedParents.add(currentId);
|
||||
currentId = entryById.get(currentId)?.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
const keptIds = new Set<string>();
|
||||
const visitingIds = new Set<string>();
|
||||
const shouldKeep = (folderId: string): boolean => {
|
||||
if (keptIds.has(folderId)) return true;
|
||||
if (visitingIds.has(folderId)) return false;
|
||||
|
||||
const entry = entryById.get(folderId);
|
||||
if (!entry) return false;
|
||||
visitingIds.add(folderId);
|
||||
|
||||
let keep = malformedIds.has(folderId);
|
||||
if (!keep && options.archivedBucket && entry.nodeCount === 0) {
|
||||
// Preserve the archived empty-folder rule: search does not make an
|
||||
// empty folder visible unless a descendant has archived content.
|
||||
keep = (childIdsByParentId.get(folderId) ?? []).some(shouldKeep);
|
||||
} else {
|
||||
if (!keep && !options.searchQuery) keep = true;
|
||||
if (!keep && (entry.nodeCount > 0 || matchesRankQuery([entry.name], options.searchQuery))) keep = true;
|
||||
if (!keep) keep = (childIdsByParentId.get(folderId) ?? []).some(shouldKeep);
|
||||
}
|
||||
|
||||
visitingIds.delete(folderId);
|
||||
if (keep) keptIds.add(folderId);
|
||||
return keep;
|
||||
};
|
||||
|
||||
entries.forEach((entry) => shouldKeep(entry.id));
|
||||
return new Set(entries.filter((entry) => keptIds.has(entry.id)).map((entry) => entry.id));
|
||||
};
|
||||
|
||||
const sessionObjectVersions = new WeakMap<object, number>();
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { SESSION_EXPANDED_STORAGE_KEY, useExpandedParents } from './useExpandedParents';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
const createStorage = (initial: string | null = null, failWrites = false): Storage => {
|
||||
const values = new Map<string, string>();
|
||||
if (initial !== null) values.set(SESSION_EXPANDED_STORAGE_KEY, initial);
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
if (failWrites) throw new Error('write failed');
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key) => { values.delete(key); },
|
||||
clear: () => values.clear(),
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
get length() { return values.size; },
|
||||
};
|
||||
};
|
||||
|
||||
type ExpandedParentsCapture = { value?: ReturnType<typeof useExpandedParents> };
|
||||
|
||||
const mountHook = async (storage: Storage) => {
|
||||
const dom = installHookTestDom(storage);
|
||||
const root = createRoot(dom.container);
|
||||
const capture: ExpandedParentsCapture = {};
|
||||
const Harness = () => {
|
||||
capture.value = useExpandedParents();
|
||||
return null;
|
||||
};
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
return { capture, root, dom };
|
||||
};
|
||||
|
||||
describe('parent expansion persistence', () => {
|
||||
test('hydrates the complete v3 set and preserves unknown/context-isolated entries when toggling', async () => {
|
||||
const initial = [
|
||||
'project:active:parent-a',
|
||||
'project:archived:parent-b',
|
||||
'recent:active:parent-a',
|
||||
'unknown:future:value',
|
||||
];
|
||||
const storage = createStorage(JSON.stringify(initial));
|
||||
const mounted = await mountHook(storage);
|
||||
try {
|
||||
expect([...mounted.capture.value!.expandedParents]).toEqual(initial);
|
||||
await act(async () => mounted.capture.value!.toggleParent('project:active:parent-a'));
|
||||
expect(JSON.parse(storage.getItem(SESSION_EXPANDED_STORAGE_KEY) ?? 'null')).toEqual(initial.slice(1));
|
||||
} finally {
|
||||
await act(async () => mounted.root.unmount());
|
||||
mounted.dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('does not write missing or malformed storage during initialization', async () => {
|
||||
for (const initial of [null, '{malformed', JSON.stringify(['valid', 2])]) {
|
||||
const storage = createStorage(initial);
|
||||
const mounted = await mountHook(storage);
|
||||
try {
|
||||
expect(mounted.capture.value!.expandedParents.size).toBe(0);
|
||||
expect(storage.getItem(SESSION_EXPANDED_STORAGE_KEY)).toBe(initial);
|
||||
} finally {
|
||||
await act(async () => mounted.root.unmount());
|
||||
mounted.dom.restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('leaves durable data unchanged on write failure and rereads it on remount', async () => {
|
||||
const raw = JSON.stringify(['recent:active:parent-a']);
|
||||
const storage = createStorage(raw, true);
|
||||
const first = await mountHook(storage);
|
||||
await act(async () => first.capture.value!.toggleParent('project:active:parent-b'));
|
||||
expect(first.capture.value!.expandedParents).toEqual(new Set([
|
||||
'recent:active:parent-a',
|
||||
'project:active:parent-b',
|
||||
]));
|
||||
expect(storage.getItem(SESSION_EXPANDED_STORAGE_KEY)).toBe(raw);
|
||||
await act(async () => first.root.unmount());
|
||||
first.dom.restore();
|
||||
|
||||
const second = await mountHook(storage);
|
||||
try {
|
||||
expect(second.capture.value!.expandedParents).toEqual(new Set(['recent:active:parent-a']));
|
||||
} finally {
|
||||
await act(async () => second.root.unmount());
|
||||
second.dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { toggleExpandedParentKey } from '../utils';
|
||||
|
||||
export const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v3';
|
||||
|
||||
const expandedParentsSchema = z.array(z.string());
|
||||
|
||||
const readExpandedParents = (): Set<string> => {
|
||||
try {
|
||||
const raw = globalThis.localStorage.getItem(SESSION_EXPANDED_STORAGE_KEY);
|
||||
if (raw === null) return new Set();
|
||||
const parsed = expandedParentsSchema.safeParse(JSON.parse(raw));
|
||||
return parsed.success ? new Set(parsed.data) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
};
|
||||
|
||||
export const useExpandedParents = () => {
|
||||
const [expandedParents, setExpandedParents] = React.useState(readExpandedParents);
|
||||
const expandedParentsRef = React.useRef(expandedParents);
|
||||
expandedParentsRef.current = expandedParents;
|
||||
|
||||
const toggleParent = React.useCallback((key: string) => {
|
||||
const next = toggleExpandedParentKey(expandedParentsRef.current, key);
|
||||
expandedParentsRef.current = next;
|
||||
setExpandedParents(next);
|
||||
try {
|
||||
globalThis.localStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify([...next]));
|
||||
} catch {
|
||||
// The mounted list keeps the user's change; a remount rereads durable storage.
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { expandedParents, toggleParent };
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import type { DeleteSessionConfirmState } from '../shell/ConfirmDialogs';
|
||||
import { useSessionActions } from './useSessionActions';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
const session = (id: string): Session => ({
|
||||
id,
|
||||
slug: id,
|
||||
projectID: 'project',
|
||||
title: id,
|
||||
version: '1',
|
||||
directory: '/workspace',
|
||||
time: { created: 1, updated: 1 },
|
||||
});
|
||||
|
||||
describe('explicit session row behavior', () => {
|
||||
test('shares edit and menu state across project and Recent render contexts', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
type SharedRowCapture = {
|
||||
actions?: ReturnType<typeof useSessionActions>;
|
||||
editingId?: string | null;
|
||||
editTitle?: string;
|
||||
menuKey?: string | null;
|
||||
setMenuKey?: (key: string | null) => void;
|
||||
project?: { editingId: string | null; editTitle: string; menuKey: string | null };
|
||||
recent?: { editingId: string | null; editTitle: string; menuKey: string | null };
|
||||
};
|
||||
const capture: SharedRowCapture = {};
|
||||
const RowConsumer = ({ context, editingId, editTitle, menuKey }: {
|
||||
context: 'project' | 'recent';
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
menuKey: string | null;
|
||||
}) => {
|
||||
capture[context] = { editingId, editTitle, menuKey };
|
||||
return null;
|
||||
};
|
||||
const Harness = () => {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [menuKey, setMenuKey] = React.useState<string | null>(null);
|
||||
const [confirmation, setConfirmation] = React.useState<DeleteSessionConfirmState>(null);
|
||||
capture.actions = useSessionActions({
|
||||
mobileVariant: false,
|
||||
allowReselect: false,
|
||||
isSessionSearchOpen: false,
|
||||
sessionSearchQuery: '',
|
||||
setSessionSearchQuery: () => undefined,
|
||||
setIsSessionSearchOpen: () => undefined,
|
||||
descendantIds: [],
|
||||
showDeletionDialog: true,
|
||||
setDeleteSessionConfirm: setConfirmation,
|
||||
deleteSessionConfirm: confirmation,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
copiedSessionId: null,
|
||||
setCopiedSessionId: () => undefined,
|
||||
});
|
||||
capture.editingId = editingId;
|
||||
capture.editTitle = editTitle;
|
||||
capture.menuKey = menuKey;
|
||||
capture.setMenuKey = setMenuKey;
|
||||
return React.createElement(React.Fragment, null,
|
||||
React.createElement(RowConsumer, { context: 'project', editingId, editTitle, menuKey }),
|
||||
React.createElement(RowConsumer, { context: 'recent', editingId, editTitle, menuKey }),
|
||||
);
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(I18nProvider, null, React.createElement(Harness))));
|
||||
await act(async () => capture.actions!.handleSessionDoubleClick('same-session', 'Shared title'));
|
||||
expect(capture.editingId).toBe('same-session');
|
||||
expect(capture.editTitle).toBe('Shared title');
|
||||
expect(capture.project).toEqual(capture.recent);
|
||||
await act(async () => capture.setMenuKey!('recent:active:same-session'));
|
||||
expect(capture.menuKey).toBe('recent:active:same-session');
|
||||
expect(capture.project).toEqual(capture.recent);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('executes the immutable descendant snapshot captured when confirmation opens', async () => {
|
||||
const dom = installHookTestDom();
|
||||
const root = createRoot(dom.container);
|
||||
const original = useSessionUIStore.getState();
|
||||
const archivedCalls: string[][] = [];
|
||||
useSessionUIStore.setState({
|
||||
archiveSessions: async (ids) => {
|
||||
archivedCalls.push(ids);
|
||||
return { archivedIds: ids, failedIds: [] };
|
||||
},
|
||||
});
|
||||
const descendants = ['child-a', 'child-b'];
|
||||
type ConfirmationCapture = {
|
||||
actions?: ReturnType<typeof useSessionActions>;
|
||||
confirmation?: DeleteSessionConfirmState;
|
||||
};
|
||||
const capture: ConfirmationCapture = {};
|
||||
const Harness = () => {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [confirmation, setConfirmation] = React.useState<DeleteSessionConfirmState>(null);
|
||||
capture.confirmation = confirmation;
|
||||
capture.actions = useSessionActions({
|
||||
mobileVariant: false,
|
||||
allowReselect: false,
|
||||
isSessionSearchOpen: false,
|
||||
sessionSearchQuery: '',
|
||||
setSessionSearchQuery: () => undefined,
|
||||
setIsSessionSearchOpen: () => undefined,
|
||||
descendantIds: descendants,
|
||||
showDeletionDialog: true,
|
||||
setDeleteSessionConfirm: setConfirmation,
|
||||
deleteSessionConfirm: confirmation,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
copiedSessionId: null,
|
||||
setCopiedSessionId: () => undefined,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(I18nProvider, null, React.createElement(Harness))));
|
||||
await act(async () => capture.actions!.handleDeleteSession(session('root')));
|
||||
expect(capture.confirmation?.descendantIds).toEqual(['child-a', 'child-b']);
|
||||
await act(async () => capture.actions!.confirmDeleteSession());
|
||||
expect(archivedCalls).toEqual([['root', 'child-a', 'child-b']]);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
useSessionUIStore.setState({ archiveSessions: original.archiveSessions });
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
+98
-101
@@ -3,25 +3,24 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
|
||||
session: Session;
|
||||
descendantCount: number;
|
||||
descendantIds: string[];
|
||||
archivedBucket: boolean;
|
||||
} | null>>;
|
||||
|
||||
type DeleteSessionSource = {
|
||||
export type DeleteSessionSource = {
|
||||
archivedBucket?: boolean;
|
||||
hardDelete?: boolean;
|
||||
/** Bypass the confirmation dialog and delete/archive immediately. */
|
||||
skipConfirm?: boolean;
|
||||
};
|
||||
|
||||
export type DeleteSessionConfirmState = {
|
||||
session: Session;
|
||||
descendantCount: number;
|
||||
descendantIds: string[];
|
||||
archivedBucket: boolean;
|
||||
} | null;
|
||||
|
||||
type Args = {
|
||||
mobileVariant: boolean;
|
||||
allowReselect: boolean;
|
||||
@@ -30,31 +29,54 @@ type Args = {
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void;
|
||||
updateSessionTitle: (id: string, title: string) => Promise<void>;
|
||||
shareSession: (id: string) => Promise<Session | null>;
|
||||
unshareSession: (id: string) => Promise<Session | null>;
|
||||
deleteSession: (id: string) => Promise<boolean>;
|
||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
archiveSession: (id: string) => Promise<boolean>;
|
||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
unarchiveSession: (id: string) => Promise<boolean>;
|
||||
childrenMap: Map<string, Session[]>;
|
||||
descendantIds: readonly string[];
|
||||
showDeletionDialog: boolean;
|
||||
setDeleteSessionConfirm: DeleteSessionConfirmSetter;
|
||||
deleteSessionConfirm: { session: Session; descendantCount: number; descendantIds: string[]; archivedBucket: boolean } | null;
|
||||
setDeleteSessionConfirm: (value: DeleteSessionConfirmState) => void;
|
||||
deleteSessionConfirm: DeleteSessionConfirmState;
|
||||
setEditingId: (id: string | null) => void;
|
||||
setEditTitle: (value: string) => void;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
copiedSessionId: string | null;
|
||||
setCopiedSessionId: (sessionId: string | null) => void;
|
||||
};
|
||||
|
||||
export const useSessionActions = (args: Args) => {
|
||||
const { t } = useI18n();
|
||||
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
||||
const copyTimeout = React.useRef<number | null>(null);
|
||||
const editingIdRef = React.useRef(args.editingId);
|
||||
const editTitleRef = React.useRef(args.editTitle);
|
||||
const deleteSessionConfirmRef = React.useRef(args.deleteSessionConfirm);
|
||||
editingIdRef.current = args.editingId;
|
||||
editTitleRef.current = args.editTitle;
|
||||
deleteSessionConfirmRef.current = args.deleteSessionConfirm;
|
||||
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
||||
const shareSession = useSessionUIStore((state) => state.shareSession);
|
||||
const unshareSession = useSessionUIStore((state) => state.unshareSession);
|
||||
const deleteSession = useSessionUIStore((state) => state.deleteSession);
|
||||
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||
const archiveSession = useSessionUIStore((state) => state.archiveSession);
|
||||
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||
|
||||
const {
|
||||
mobileVariant,
|
||||
allowReselect,
|
||||
onSessionSelected,
|
||||
isSessionSearchOpen,
|
||||
sessionSearchQuery,
|
||||
setSessionSearchQuery,
|
||||
setIsSessionSearchOpen,
|
||||
descendantIds,
|
||||
showDeletionDialog,
|
||||
setDeleteSessionConfirm,
|
||||
setEditingId,
|
||||
setEditTitle,
|
||||
setCopiedSessionId,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
@@ -71,52 +93,52 @@ export const useSessionActions = (args: Args) => {
|
||||
// the session is already the current one (no store transition fires).
|
||||
useUIStore.getState().closeMainSurfaces();
|
||||
const resetSessionSearch = () => {
|
||||
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
||||
if (!isSessionSearchOpen && sessionSearchQuery.length === 0) {
|
||||
return;
|
||||
}
|
||||
args.setSessionSearchQuery('');
|
||||
args.setIsSessionSearchOpen(false);
|
||||
setSessionSearchQuery('');
|
||||
setIsSessionSearchOpen(false);
|
||||
};
|
||||
|
||||
if (args.mobileVariant) {
|
||||
args.setActiveMainTab('chat');
|
||||
args.setSessionSwitcherOpen(false);
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
|
||||
if (sessionId === useSessionUIStore.getState().currentSessionId) {
|
||||
if (args.allowReselect) {
|
||||
args.onSessionSelected?.(sessionId);
|
||||
if (allowReselect) {
|
||||
onSessionSelected?.(sessionId);
|
||||
}
|
||||
resetSessionSearch();
|
||||
return;
|
||||
}
|
||||
streamPerfMark('navigation.session_state_set');
|
||||
args.setCurrentSession(sessionId, sessionDirectory ?? null);
|
||||
args.onSessionSelected?.(sessionId);
|
||||
setCurrentSession(sessionId, sessionDirectory ?? null);
|
||||
onSessionSelected?.(sessionId);
|
||||
resetSessionSearch();
|
||||
},
|
||||
[args],
|
||||
[allowReselect, isSessionSearchOpen, mobileVariant, onSessionSelected, sessionSearchQuery, setCurrentSession, setIsSessionSearchOpen, setSessionSearchQuery, setSessionSwitcherOpen],
|
||||
);
|
||||
|
||||
const handleSessionDoubleClick = React.useCallback((sessionId: string, sessionTitle: string) => {
|
||||
args.setEditingId(sessionId);
|
||||
args.setEditTitle(sessionTitle);
|
||||
}, [args]);
|
||||
setEditingId(sessionId);
|
||||
setEditTitle(sessionTitle);
|
||||
}, [setEditTitle, setEditingId]);
|
||||
|
||||
const handleSaveEdit = React.useCallback(async (titleOverride?: string) => {
|
||||
if (!args.editingId) return;
|
||||
const trimmed = (titleOverride ?? args.editTitle).trim();
|
||||
const editingId = editingIdRef.current;
|
||||
if (!editingId) return;
|
||||
const trimmed = (titleOverride ?? editTitleRef.current).trim();
|
||||
if (trimmed) {
|
||||
await args.updateSessionTitle(args.editingId, trimmed);
|
||||
await updateSessionTitle(editingId, trimmed);
|
||||
}
|
||||
args.setEditingId(null);
|
||||
args.setEditTitle('');
|
||||
}, [args]);
|
||||
setEditingId(null);
|
||||
setEditTitle('');
|
||||
}, [setEditTitle, setEditingId, updateSessionTitle]);
|
||||
|
||||
const handleCancelEdit = React.useCallback(() => {
|
||||
args.setEditingId(null);
|
||||
args.setEditTitle('');
|
||||
}, [args]);
|
||||
setEditingId(null);
|
||||
setEditTitle('');
|
||||
}, [setEditTitle, setEditingId]);
|
||||
|
||||
const copyShareUrl = React.useCallback(async (url: string, sessionId: string): Promise<boolean> => {
|
||||
try {
|
||||
@@ -132,10 +154,10 @@ export const useSessionActions = (args: Args) => {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
}, [setCopiedSessionId]);
|
||||
|
||||
const handleShareSession = React.useCallback(async (session: Session) => {
|
||||
const result = await args.shareSession(session.id);
|
||||
const result = await shareSession(session.id);
|
||||
if (!result?.share?.url) {
|
||||
toast.error(t('sessions.sidebar.session.share.error'));
|
||||
return;
|
||||
@@ -146,7 +168,7 @@ export const useSessionActions = (args: Args) => {
|
||||
? 'sessions.sidebar.session.share.successDescription'
|
||||
: 'sessions.sidebar.session.share.copyUrlError'),
|
||||
});
|
||||
}, [args, copyShareUrl, t]);
|
||||
}, [copyShareUrl, shareSession, t]);
|
||||
|
||||
const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => {
|
||||
void copyShareUrl(url, sessionId).then((copied) => {
|
||||
@@ -167,37 +189,13 @@ export const useSessionActions = (args: Args) => {
|
||||
}, [t]);
|
||||
|
||||
const handleUnshareSession = React.useCallback(async (sessionId: string) => {
|
||||
const result = await args.unshareSession(sessionId);
|
||||
const result = await unshareSession(sessionId);
|
||||
if (result) {
|
||||
toast.success(t('sessions.sidebar.session.unshare.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.unshare.error'));
|
||||
}
|
||||
}, [args, t]);
|
||||
|
||||
const collectDescendants = React.useCallback((sessionId: string): Session[] => {
|
||||
const collected: Session[] = [];
|
||||
const visit = (id: string) => {
|
||||
const children = args.childrenMap.get(id) ?? [];
|
||||
children.forEach((child) => {
|
||||
collected.push(child);
|
||||
visit(child.id);
|
||||
});
|
||||
};
|
||||
visit(sessionId);
|
||||
return collected;
|
||||
}, [args.childrenMap]);
|
||||
|
||||
// Archive cascades to subagents that aren't already archived; hard-delete
|
||||
// cascades to every descendant unconditionally. We collect once and filter
|
||||
// per-action so the dialog count and the executed ID list always agree.
|
||||
const filterDescendantsForAction = React.useCallback(
|
||||
(descendants: Session[], shouldHardDelete: boolean): Session[] => {
|
||||
if (shouldHardDelete) return descendants;
|
||||
return descendants.filter((s) => !s.time?.archived);
|
||||
},
|
||||
[],
|
||||
);
|
||||
}, [t, unshareSession]);
|
||||
|
||||
const executeDeleteSession = React.useCallback(
|
||||
async (
|
||||
@@ -209,12 +207,12 @@ export const useSessionActions = (args: Args) => {
|
||||
// Use the snapshot taken when the dialog opened (if any) so the
|
||||
// executed list matches what the user was told. Fall back to a fresh
|
||||
// collection for direct-execute (no-dialog) callers.
|
||||
const descendantIds = precomputed?.descendantIds
|
||||
?? filterDescendantsForAction(collectDescendants(session.id), shouldHardDelete).map((s) => s.id);
|
||||
if (descendantIds.length === 0) {
|
||||
const effectiveDescendantIds = precomputed?.descendantIds
|
||||
?? descendantIds;
|
||||
if (effectiveDescendantIds.length === 0) {
|
||||
const success = shouldHardDelete
|
||||
? await args.deleteSession(session.id)
|
||||
: await args.archiveSession(session.id);
|
||||
? await deleteSession(session.id)
|
||||
: await archiveSession(session.id);
|
||||
if (success) {
|
||||
toast.success(shouldHardDelete
|
||||
? t('sessions.sidebar.session.delete.success')
|
||||
@@ -227,12 +225,12 @@ export const useSessionActions = (args: Args) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = [session.id, ...descendantIds];
|
||||
const ids = [session.id, ...effectiveDescendantIds];
|
||||
if (shouldHardDelete) {
|
||||
// Delete root + all descendants individually. If the server
|
||||
// cascade-deletes some children before we get to them, 404 is
|
||||
// treated as success by deleteSession and no rollback occurs.
|
||||
const { deletedIds, failedIds } = await args.deleteSessions(ids);
|
||||
const { deletedIds, failedIds } = await deleteSessions(ids);
|
||||
if (failedIds.length === 0) {
|
||||
const totalDeleted = deletedIds.length;
|
||||
toast.success(totalDeleted === 1
|
||||
@@ -244,7 +242,7 @@ export const useSessionActions = (args: Args) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { archivedIds, failedIds } = await args.archiveSessions(ids);
|
||||
const { archivedIds, failedIds } = await archiveSessions(ids);
|
||||
if (archivedIds.length > 0) {
|
||||
toast.success(archivedIds.length === 1
|
||||
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
|
||||
@@ -256,51 +254,48 @@ export const useSessionActions = (args: Args) => {
|
||||
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
|
||||
}
|
||||
},
|
||||
[args, collectDescendants, filterDescendantsForAction, t],
|
||||
[archiveSession, archiveSessions, deleteSession, deleteSessions, descendantIds, t],
|
||||
);
|
||||
|
||||
const handleDeleteSession = React.useCallback(
|
||||
(session: Session, source?: DeleteSessionSource) => {
|
||||
const shouldHardDelete = source?.archivedBucket === true || source?.hardDelete === true;
|
||||
const effectiveDescendantIds = filterDescendantsForAction(
|
||||
collectDescendants(session.id),
|
||||
shouldHardDelete,
|
||||
).map((s) => s.id);
|
||||
if (!args.showDeletionDialog || source?.skipConfirm === true) {
|
||||
const effectiveDescendantIds = [...descendantIds];
|
||||
if (!showDeletionDialog || source?.skipConfirm === true) {
|
||||
void executeDeleteSession(session, source, { descendantIds: effectiveDescendantIds });
|
||||
return;
|
||||
}
|
||||
args.setDeleteSessionConfirm({
|
||||
setDeleteSessionConfirm({
|
||||
session,
|
||||
descendantCount: effectiveDescendantIds.length,
|
||||
descendantIds: effectiveDescendantIds,
|
||||
archivedBucket: shouldHardDelete,
|
||||
});
|
||||
},
|
||||
[args, collectDescendants, executeDeleteSession, filterDescendantsForAction],
|
||||
[descendantIds, executeDeleteSession, setDeleteSessionConfirm, showDeletionDialog],
|
||||
);
|
||||
|
||||
const confirmDeleteSession = React.useCallback(async () => {
|
||||
if (!args.deleteSessionConfirm) return;
|
||||
const { session, archivedBucket, descendantIds } = args.deleteSessionConfirm;
|
||||
args.setDeleteSessionConfirm(null);
|
||||
const deleteSessionConfirm = deleteSessionConfirmRef.current;
|
||||
if (!deleteSessionConfirm) return;
|
||||
const { session, archivedBucket, descendantIds } = deleteSessionConfirm;
|
||||
setDeleteSessionConfirm(null);
|
||||
await executeDeleteSession(session, { archivedBucket }, { descendantIds });
|
||||
}, [args, executeDeleteSession]);
|
||||
}, [executeDeleteSession, setDeleteSessionConfirm]);
|
||||
|
||||
const handleRestoreSession = React.useCallback(
|
||||
async (session: Session) => {
|
||||
const success = await args.unarchiveSession(session.id);
|
||||
const success = await unarchiveSession(session.id);
|
||||
if (success) {
|
||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||
} else {
|
||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||
}
|
||||
},
|
||||
[args, t],
|
||||
[t, unarchiveSession],
|
||||
);
|
||||
|
||||
return {
|
||||
copiedSessionId,
|
||||
return React.useMemo(() => ({
|
||||
handleSessionSelect,
|
||||
handleSessionDoubleClick,
|
||||
handleSaveEdit,
|
||||
@@ -312,5 +307,7 @@ export const useSessionActions = (args: Args) => {
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
};
|
||||
}), [handleCancelEdit, handleCopySessionId, handleCopyShareUrl, handleDeleteSession,
|
||||
handleRestoreSession, handleSaveEdit, handleSessionDoubleClick, handleSessionSelect, handleShareSession,
|
||||
handleUnshareSession, confirmDeleteSession]);
|
||||
};
|
||||
+66
-25
@@ -12,10 +12,13 @@ import { cn } from '@/lib/utils';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
|
||||
type Props = {
|
||||
hideDirectoryControls: boolean;
|
||||
showProjectDisplayControls: boolean;
|
||||
showRecentControls: boolean;
|
||||
handleOpenDirectoryDialog: () => void;
|
||||
onOpenScheduled: () => void;
|
||||
@@ -33,14 +36,13 @@ type Props = {
|
||||
searchMatchCount: number;
|
||||
collapseAllProjects: () => void;
|
||||
expandAllProjects: () => void;
|
||||
selectionModeEnabled: boolean;
|
||||
onToggleSelectionMode: () => void;
|
||||
};
|
||||
|
||||
export function SidebarHeader(props: Props): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
hideDirectoryControls,
|
||||
showProjectDisplayControls,
|
||||
showRecentControls,
|
||||
handleOpenDirectoryDialog,
|
||||
onOpenScheduled,
|
||||
@@ -58,10 +60,11 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
searchMatchCount,
|
||||
collapseAllProjects,
|
||||
expandAllProjects,
|
||||
selectionModeEnabled,
|
||||
onToggleSelectionMode,
|
||||
} = props;
|
||||
|
||||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||
const toggleSelectionMode = useSessionMultiSelectStore((state) => state.toggleMode);
|
||||
|
||||
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
||||
const toggleRecentSection = useSessionDisplayStore((state) => state.toggleRecentSection);
|
||||
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
||||
@@ -70,6 +73,9 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
const setSessionGroupingMode = useSessionDisplayStore((state) => state.setSessionGroupingMode);
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const toggleStickyZoneHeaders = useSessionDisplayStore((state) => state.toggleStickyZoneHeaders);
|
||||
const projectDisplayMode = useSessionDisplayStore((state) => state.projectDisplayMode);
|
||||
const setProjectDisplayMode = useSessionDisplayStore((state) => state.setProjectDisplayMode);
|
||||
const isSingleProjectMode = showProjectDisplayControls && projectDisplayMode === 'single';
|
||||
|
||||
if (hideDirectoryControls) {
|
||||
return null;
|
||||
@@ -84,7 +90,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
icon inset inside the 24px buttons so the first glyph lines up
|
||||
with the New-session icon above (16px from the sidebar edge). */}
|
||||
<div className="ml-[3px] flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -98,7 +104,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.addProject')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -112,7 +118,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -127,7 +133,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newMultiRun')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -143,7 +149,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -158,11 +164,11 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.searchSessions')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSelectionMode}
|
||||
onClick={toggleSelectionMode}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent', selectionModeEnabled && 'bg-interactive-hover text-primary')}
|
||||
aria-label={selectionModeEnabled
|
||||
? t('sessions.sidebar.header.actions.exitSelection')
|
||||
@@ -180,7 +186,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -205,7 +211,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
] as const).map(([order, labelKey]) => (
|
||||
<DropdownMenuItem
|
||||
key={order}
|
||||
onClick={() => setProjectSortOrder(order)}
|
||||
onClick={() => {
|
||||
setProjectSortOrder(order);
|
||||
void updateDesktopSettings({ sidebarProjectSortOrder: order });
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t(labelKey)}</span>
|
||||
@@ -213,6 +222,28 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{showProjectDisplayControls ? (
|
||||
<>
|
||||
<DropdownMenuLabel>{t('sessions.sidebar.header.projectDisplay.label')}</DropdownMenuLabel>
|
||||
{([
|
||||
['all', 'sessions.sidebar.header.projectDisplay.all'],
|
||||
['single', 'sessions.sidebar.header.projectDisplay.single'],
|
||||
] as const).map(([mode, labelKey]) => (
|
||||
<DropdownMenuItem
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
setProjectDisplayMode(mode);
|
||||
void updateDesktopSettings({ sidebarProjectDisplayMode: mode });
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t(labelKey)}</span>
|
||||
{projectDisplayMode === mode ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuLabel>{t('sessions.sidebar.header.grouping.label')}</DropdownMenuLabel>
|
||||
{([
|
||||
['by-worktree', 'sessions.sidebar.header.grouping.byWorktree'],
|
||||
@@ -220,7 +251,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
] as const).map(([mode, labelKey]) => (
|
||||
<DropdownMenuItem
|
||||
key={mode}
|
||||
onClick={() => setSessionGroupingMode(mode)}
|
||||
onClick={() => {
|
||||
setSessionGroupingMode(mode);
|
||||
void updateDesktopSettings({ sidebarSessionGroupingMode: mode });
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t(labelKey)}</span>
|
||||
@@ -228,9 +262,12 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{showRecentControls ? (
|
||||
{showRecentControls && !isSingleProjectMode ? (
|
||||
<DropdownMenuItem
|
||||
onClick={toggleRecentSection}
|
||||
onClick={() => {
|
||||
toggleRecentSection();
|
||||
void updateDesktopSettings({ sidebarShowRecentSection: !showRecentSection });
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.showRecent')}</span>
|
||||
@@ -244,15 +281,19 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<span>{t('sessions.sidebar.header.displayMode.stickyHeaders')}</span>
|
||||
{stickyZoneHeaders ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
|
||||
<Icon name="contract-up-down" className="h-4 w-4" />
|
||||
<span>{t('sessions.sidebar.header.displayMode.collapseAll')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={expandAllProjects} className="flex items-center gap-2">
|
||||
<Icon name="expand-up-down" className="h-4 w-4" />
|
||||
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
|
||||
</DropdownMenuItem>
|
||||
{!isSingleProjectMode ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
|
||||
<Icon name="contract-up-down" className="h-4 w-4" />
|
||||
<span>{t('sessions.sidebar.header.displayMode.collapseAll')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={expandAllProjects} className="flex items-center gap-2">
|
||||
<Icon name="expand-up-down" className="h-4 w-4" />
|
||||
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
+15
@@ -26,6 +26,21 @@ export const useSessionSearchEffects = ({
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [enabled, isSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
// The open_session_list shortcut lands here when the sidebar is visible:
|
||||
// the session list is already on screen, so the shortcut opens its search.
|
||||
React.useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handleOpenRequest = () => {
|
||||
setIsSessionSearchOpen(true);
|
||||
sessionSearchInputRef.current?.focus();
|
||||
sessionSearchInputRef.current?.select();
|
||||
};
|
||||
window.addEventListener('openchamber:sidebar-session-search', handleOpenRequest);
|
||||
return () => window.removeEventListener('openchamber:sidebar-session-search', handleOpenRequest);
|
||||
}, [enabled, setIsSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') {
|
||||
return;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import {
|
||||
findSwitcherItemAncestorIds,
|
||||
selectSwitcherParents,
|
||||
type SwitcherItem,
|
||||
} from './useSwitcherItems';
|
||||
|
||||
const session = (id: string, options: { parentID?: string; archived?: boolean; projectId?: string } = {}): Session => ({
|
||||
id,
|
||||
parentID: options.parentID,
|
||||
time: options.archived ? { archived: Date.now() } : undefined,
|
||||
projectId: options.projectId ?? 'project-a',
|
||||
} as unknown as Session);
|
||||
|
||||
const selectParents = (sessions: Session[], currentSessionId: string | null, scopeProjectId: string | null = null): Session[] => (
|
||||
selectSwitcherParents(sessions, new Set(), new Map(), scopeProjectId, currentSessionId, (item) => (item as Session & { projectId: string }).projectId)
|
||||
);
|
||||
|
||||
describe('session switcher initial selection', () => {
|
||||
test('finds all local ancestors for a current child session', () => {
|
||||
const items: SwitcherItem[] = [{
|
||||
node: { session: session('root'), worktree: null, children: [{ session: session('parent', { parentID: 'root' }), worktree: null, children: [{ session: session('child', { parentID: 'parent' }), worktree: null, children: [] }] }] },
|
||||
projectId: 'project-a', groupDirectory: null, secondaryMeta: null,
|
||||
}];
|
||||
|
||||
expect(findSwitcherItemAncestorIds(items, 'child')).toEqual(['root', 'parent']);
|
||||
expect(findSwitcherItemAncestorIds(items, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
test('replaces the final recent slot with the current root and excludes invalid current sessions', () => {
|
||||
const roots = Array.from({ length: 8 }, (_, index) => session(`root-${index}`));
|
||||
const child = session('child', { parentID: 'root-7' });
|
||||
|
||||
expect(selectParents([...roots, child], 'child').map((item) => item.id)).toEqual([
|
||||
'root-0', 'root-1', 'root-2', 'root-3', 'root-4', 'root-5', 'root-7',
|
||||
]);
|
||||
expect(selectParents([...roots, child], 'missing').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
|
||||
expect(selectParents([...roots, child], 'child', 'project-b').map((item) => item.id)).toEqual([]);
|
||||
expect(selectParents([...roots.slice(0, 7), session('archived', { archived: true })], 'archived').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
|
||||
expect(selectParents([...roots, session('archived-child', { archived: true, parentID: 'root-7' })], 'archived-child').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
|
||||
});
|
||||
});
|
||||
+79
-12
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { isBtwSession } from '@/lib/sessionBtwMetadata';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
import { useGitAllBranches } from '@/stores/useGitStore';
|
||||
@@ -9,6 +10,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;
|
||||
@@ -24,6 +27,7 @@ const MAX_PARENT_SESSIONS = 7;
|
||||
|
||||
type SwitcherItemsOptions = {
|
||||
scopeProjectId?: string | null;
|
||||
currentSessionId?: string | null;
|
||||
/** How many parent sessions to return (default 7 — the desktop dropdown). */
|
||||
maxParents?: number;
|
||||
};
|
||||
@@ -43,14 +47,76 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n
|
||||
return segments[segments.length - 1] ?? null;
|
||||
};
|
||||
|
||||
export const findSwitcherItemAncestorIds = (items: SwitcherItem[], sessionId: string): string[] | null => {
|
||||
const visit = (node: SessionNode, ancestors: string[]): string[] | null => {
|
||||
if (node.session.id === sessionId) return ancestors;
|
||||
for (const child of node.children) {
|
||||
const result = visit(child, [...ancestors, node.session.id]);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const result = visit(item.node, []);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const selectSwitcherParents = (
|
||||
activeSessions: Session[],
|
||||
pinnedSessionIds: Set<string>,
|
||||
sessionOrderRanks: Map<string, number>,
|
||||
scopeProjectId: string | null,
|
||||
currentSessionId: string | null,
|
||||
getProjectId: (session: Session) => string | null,
|
||||
maxParents = MAX_PARENT_SESSIONS,
|
||||
isExcluded?: (session: Session) => boolean,
|
||||
): Session[] => {
|
||||
const sessionsById = new Map(activeSessions.map((session) => [session.id, session]));
|
||||
const isEligibleParent = (session: Session): boolean => {
|
||||
if (session.time?.archived) return false;
|
||||
if (isExcluded?.(session)) return false;
|
||||
// SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions.
|
||||
if ((session as Session & { parentID?: string | null }).parentID) return false;
|
||||
return !scopeProjectId || getProjectId(session) === scopeProjectId;
|
||||
};
|
||||
const parents = activeSessions
|
||||
.filter(isEligibleParent)
|
||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
||||
|
||||
const currentSession = currentSessionId ? sessionsById.get(currentSessionId) ?? null : null;
|
||||
let currentRoot: Session | null = currentSession?.time?.archived ? null : currentSession;
|
||||
const visited = new Set<string>();
|
||||
while (currentRoot) {
|
||||
// SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions.
|
||||
const parentId = (currentRoot as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentId) break;
|
||||
if (visited.has(parentId)) {
|
||||
currentRoot = null;
|
||||
break;
|
||||
}
|
||||
visited.add(parentId);
|
||||
currentRoot = sessionsById.get(parentId) ?? null;
|
||||
}
|
||||
|
||||
const currentRootIndex = currentRoot && isEligibleParent(currentRoot) ? parents.indexOf(currentRoot) : -1;
|
||||
if (currentRootIndex >= maxParents) {
|
||||
return [...parents.slice(0, Math.max(0, maxParents - 1)), currentRoot!];
|
||||
}
|
||||
return parents.slice(0, maxParents);
|
||||
};
|
||||
|
||||
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
|
||||
const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options;
|
||||
const { scopeProjectId = null, currentSessionId = null, maxParents = MAX_PARENT_SESSIONS } = options;
|
||||
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||
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
|
||||
@@ -112,16 +178,17 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
||||
list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
||||
});
|
||||
|
||||
const parents = activeSessions
|
||||
.filter((session) => !session.time?.archived)
|
||||
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
|
||||
.filter((session) => {
|
||||
if (!scopeProjectId) return true;
|
||||
const directory = resolveGlobalSessionDirectory(session);
|
||||
return findProjectForDirectory(directory)?.id === scopeProjectId;
|
||||
})
|
||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks))
|
||||
.slice(0, maxParents);
|
||||
const parents = selectSwitcherParents(
|
||||
activeSessions,
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
scopeProjectId,
|
||||
currentSessionId,
|
||||
(session) => findProjectForDirectory(resolveGlobalSessionDirectory(session))?.id ?? null,
|
||||
maxParents,
|
||||
// btw forks stay hidden until promoted to a full session
|
||||
(session) => isBtwSession(session) || (isVSCode && isChatDirectoryPath(resolveGlobalSessionDirectory(session))),
|
||||
);
|
||||
|
||||
const buildNode = (session: Session): SessionNode => {
|
||||
const childSessions = childrenByParent.get(session.id) ?? [];
|
||||
@@ -151,7 +218,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
|
||||
}, [activeSessions, branchesByDirectory, currentSessionId, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
|
||||
|
||||
return items;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
class ElementStub implements Partial<Element> {
|
||||
nodeType = 1;
|
||||
}
|
||||
|
||||
type DocumentStub = {
|
||||
nodeType: number;
|
||||
defaultView: typeof globalThis;
|
||||
activeElement: null;
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
querySelectorAll: () => never[];
|
||||
createElement: (tagName: string) => Element;
|
||||
createElementNS: (namespace: string, tagName: string) => Element;
|
||||
createTextNode: (text: string) => Text;
|
||||
documentElement?: Element;
|
||||
body?: Element;
|
||||
};
|
||||
|
||||
type GlobalValue = typeof globalThis | typeof ElementStub | DocumentStub | Storage | boolean;
|
||||
|
||||
export const installHookTestDom = (storage?: Storage) => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: GlobalValue) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
const createElement = (ownerDocument: DocumentStub): Element => {
|
||||
// SAFETY: React only uses these DOM identity, child-list, and listener methods in this test fixture.
|
||||
const element = Object.create(ElementStub.prototype) as Element;
|
||||
Object.assign(element, {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument,
|
||||
parentNode: null,
|
||||
parentElement: null,
|
||||
childNodes: [],
|
||||
style: { setProperty: () => undefined, getPropertyValue: () => '' },
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
appendChild<T extends Node>(child: T): T {
|
||||
// SAFETY: every fixture child is a Node supplied by React's host renderer.
|
||||
(this.childNodes as Node[]).push(child);
|
||||
return child;
|
||||
},
|
||||
insertBefore<T extends Node>(child: T): T {
|
||||
// SAFETY: every fixture child is a Node supplied by React's host renderer.
|
||||
(this.childNodes as Node[]).push(child);
|
||||
return child;
|
||||
},
|
||||
removeChild<T extends Node>(child: T): T {
|
||||
// SAFETY: this fixture stores only Node children from React's host renderer.
|
||||
const children = this.childNodes as Node[];
|
||||
const index = children.indexOf(child);
|
||||
if (index >= 0) children.splice(index, 1);
|
||||
return child;
|
||||
},
|
||||
setAttribute: () => undefined,
|
||||
removeAttribute: () => undefined,
|
||||
getAttribute: () => null,
|
||||
hasAttribute: () => false,
|
||||
contains: () => false,
|
||||
compareDocumentPosition: () => 0,
|
||||
});
|
||||
return element;
|
||||
};
|
||||
const documentStub: DocumentStub = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
querySelectorAll: () => [],
|
||||
createElement: () => createElement(documentStub),
|
||||
createElementNS: () => createElement(documentStub),
|
||||
// SAFETY: React only checks the text node identity field in this fixture.
|
||||
createTextNode: () => ({ nodeType: 3 } as Text),
|
||||
};
|
||||
// SAFETY: React's test renderer only inspects this fixture's DOM identity fields and listeners.
|
||||
const container = createElement(documentStub);
|
||||
Object.assign(documentStub, { documentElement: container, body: container });
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
if (storage) setGlobal('localStorage', storage);
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
return {
|
||||
container,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -29,6 +29,8 @@ export type SessionGroup = {
|
||||
* instead of reading the single folderScopeKey.
|
||||
*/
|
||||
folderScopes?: SessionGroupFolderScope[];
|
||||
draftTarget?: 'chat' | 'project';
|
||||
emptyMessage?: string;
|
||||
sessions: SessionNode[];
|
||||
};
|
||||
|
||||
|
||||
@@ -156,11 +156,8 @@ export const resolveArchivedFolderName = (session: Session, projectRoot: string
|
||||
return segments[segments.length - 1] ?? 'unassigned';
|
||||
};
|
||||
|
||||
export const formatProjectLabel = (label: string): string => {
|
||||
return label
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
// Folder names are shown exactly as they are on disk — no title-casing.
|
||||
export const formatProjectLabel = (label: string): string => label.trim();
|
||||
|
||||
export const renderHighlightedText = (text: string, query: string): React.ReactNode => {
|
||||
if (!query) {
|
||||
|
||||
Reference in New Issue
Block a user