feat: move projects to sidebar rail and speed up session switching (#506)
* feat: move projects from header tabs to left sidebar rail * refactor: remove variant from git generation session context * feat: implemented drandrop action for navrails * refactor: improve sidebar drag-and-drop smoothness and remove floating overlay * fix: prevent mobile nav rail touch from closing drawer and opening menu * fix: hide header tabs on desktop * fix: prevent session flicker when switching projects * style: soften chat panel divider borders * style: improve icon contrast across core navigation * fix: stabilize session selection during project switching * style: unify sidebar surfaces and transparent section layers * style: remove UI shadows and keep only scroll shadow * perf: speed up project switching with cached session loading * perf: speed up Git changes view and background refresh * perf: make session switching lighter and less aggressive * perf: optimized sessions list loading while project switching * perf: smooth chat rendering and reduce interaction spikes * fix: restore reliable load older messages visibility
This commit is contained in:
committed by
GitHub
parent
4c69bccf56
commit
10851bd7ac
@@ -276,7 +276,7 @@ export const DirectoryAutocomplete = React.forwardRef<DirectoryAutocompleteHandl
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] w-full max-h-48 bg-background border border-border rounded-lg shadow-lg top-full mt-1 left-0 flex flex-col overflow-hidden"
|
||||
className="absolute z-[100] w-full max-h-48 bg-background border border-border rounded-lg shadow-none top-full mt-1 left-0 flex flex-col overflow-hidden"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
useSensors,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
type Modifier,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
@@ -114,6 +113,10 @@ const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
|
||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
||||
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
|
||||
|
||||
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
|
||||
const SESSION_PREFETCH_CONCURRENCY = 1;
|
||||
const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
||||
|
||||
const formatDateLabel = (value: string | number) => {
|
||||
const targetDate = new Date(value);
|
||||
const today = new Date();
|
||||
@@ -218,19 +221,6 @@ const compareSessionsByPinnedAndTime = (
|
||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||
};
|
||||
|
||||
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
|
||||
if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) {
|
||||
return transform;
|
||||
}
|
||||
const overlayHeight = 32;
|
||||
const pointerLiftY = 16;
|
||||
return {
|
||||
...transform,
|
||||
x: transform.x,
|
||||
y: transform.y - overlayHeight / 2 - pointerLiftY,
|
||||
};
|
||||
};
|
||||
|
||||
// Format project label: kebab-case/snake_case → Title Case
|
||||
const formatProjectLabel = (label: string): string => {
|
||||
return label
|
||||
@@ -289,7 +279,7 @@ const DraggableSessionRow: React.FC<{
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
onPointerDown={handlePointerDown}
|
||||
className={isDragging ? 'opacity-40' : undefined}
|
||||
className={isDragging ? 'opacity-30' : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -375,14 +365,14 @@ const SessionFolderDndScope: React.FC<{
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<DragOverlay>
|
||||
{activeDragId && hasFolders ? (
|
||||
<div
|
||||
style={{
|
||||
width: activeDragWidth ? `${activeDragWidth}px` : 'auto',
|
||||
height: activeDragHeight ? `${activeDragHeight}px` : 'auto'
|
||||
}}
|
||||
className="flex items-center rounded-md border border-border bg-sidebar px-1.5 py-1 shadow-lg opacity-90 pointer-events-none"
|
||||
className="flex items-center rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1 shadow-none pointer-events-none"
|
||||
>
|
||||
<RiStickyNoteLine className="h-4 w-4 text-muted-foreground mr-2 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
||||
@@ -466,13 +456,19 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={cn('relative', isDragging && 'opacity-40')}>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn('relative', isDragging && 'opacity-30')}
|
||||
>
|
||||
{!hideHeader ? (
|
||||
<>
|
||||
{/* Sentinel for sticky detection */}
|
||||
@@ -489,11 +485,11 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none',
|
||||
!isDesktopShell && 'bg-sidebar',
|
||||
!isDesktopShell && 'bg-transparent',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isDesktopShell
|
||||
? isStuck ? 'var(--sidebar-stuck-bg)' : 'transparent'
|
||||
? (isStuck ? 'transparent' : 'transparent')
|
||||
: undefined,
|
||||
borderColor: isHovered
|
||||
? 'var(--color-border-hover)'
|
||||
@@ -708,10 +704,13 @@ const SortableGroupItemBase: React.FC<{
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition, willChange: 'transform' }}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
className={cn(
|
||||
'space-y-0.5 rounded-md',
|
||||
isDragging && 'opacity-0',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
@@ -723,16 +722,7 @@ const SortableGroupItemBase: React.FC<{
|
||||
|
||||
const SortableGroupItem = React.memo(SortableGroupItemBase);
|
||||
|
||||
const GroupDragOverlayBase: React.FC<{ label: string; showBranchIcon: boolean; width?: number }> = ({ label, showBranchIcon, width }) => {
|
||||
return (
|
||||
<div style={width ? { width: `${width}px` } : undefined} className="h-8 min-w-[180px] max-w-[320px] rounded-sm border border-border bg-sidebar px-2 shadow-lg flex items-center gap-1.5">
|
||||
{showBranchIcon ? <RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" /> : null}
|
||||
<p className="text-[15px] font-semibold truncate text-foreground">{label}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupDragOverlay = React.memo(GroupDragOverlayBase);
|
||||
|
||||
interface SessionSidebarProps {
|
||||
mobileVariant?: boolean;
|
||||
@@ -748,7 +738,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
onSessionSelected,
|
||||
allowReselect = false,
|
||||
hideDirectoryControls = false,
|
||||
hideProjectSelector = false,
|
||||
hideProjectSelector = true,
|
||||
showOnlyMainWorkspace = false,
|
||||
}) => {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
@@ -761,6 +751,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const [directoryStatus, setDirectoryStatus] = React.useState<Map<string, 'unknown' | 'exists' | 'missing'>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const directoryStatusRef = React.useRef<Map<string, 'unknown' | 'exists' | 'missing'>>(new Map());
|
||||
const checkingDirectories = React.useRef<Set<string>>(new Set());
|
||||
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
||||
@@ -847,8 +838,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return new Map();
|
||||
}
|
||||
});
|
||||
const [activeDraggedGroupId, setActiveDraggedGroupId] = React.useState<string | null>(null);
|
||||
const [activeDraggedGroupWidth, setActiveDraggedGroupWidth] = React.useState<number | null>(null);
|
||||
|
||||
const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
|
||||
const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
|
||||
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
|
||||
@@ -865,7 +855,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const addProject = useProjectsStore((state) => state.addProject);
|
||||
const removeProject = useProjectsStore((state) => state.removeProject);
|
||||
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
const renameProject = useProjectsStore((state) => state.renameProject);
|
||||
|
||||
@@ -879,9 +868,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||
|
||||
// Session Folders store
|
||||
// Subscribe to foldersMap so renderSessionNode/renderGroupSessions re-run when any folder changes.
|
||||
// getFoldersForScope is a stable function selector and does not trigger re-renders on its own.
|
||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||
const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds);
|
||||
const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope);
|
||||
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||
@@ -900,6 +886,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||
const loadMessages = useSessionStore((state) => state.loadMessages);
|
||||
const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle);
|
||||
const shareSession = useSessionStore((state) => state.shareSession);
|
||||
const unshareSession = useSessionStore((state) => state.unshareSession);
|
||||
@@ -1022,6 +1009,99 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||
}, [sessions, pinnedSessionIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
directoryStatusRef.current = directoryStatus;
|
||||
}, [directoryStatus]);
|
||||
|
||||
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) {
|
||||
const nextSessionId = sessionPrefetchQueueRef.current.shift();
|
||||
if (!nextSessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
if (state.currentSessionId === nextSessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasMessages = state.messages.has(nextSessionId);
|
||||
const memory = state.sessionMemoryState.get(nextSessionId);
|
||||
const isHydrated = hasMessages && memory?.historyComplete !== undefined;
|
||||
if (isHydrated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sessionPrefetchInFlightRef.current.add(nextSessionId);
|
||||
void loadMessages(nextSessionId)
|
||||
.catch(() => {
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
sessionPrefetchInFlightRef.current.delete(nextSessionId);
|
||||
pumpSessionPrefetchQueue();
|
||||
});
|
||||
}
|
||||
}, [loadMessages]);
|
||||
|
||||
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
|
||||
if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
const hasMessages = state.messages.has(sessionId);
|
||||
const memory = state.sessionMemoryState.get(sessionId);
|
||||
const isHydrated = hasMessages && memory?.historyComplete !== undefined;
|
||||
if (isHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchInFlightRef.current.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.includes(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
||||
sessionPrefetchQueueRef.current.shift();
|
||||
}
|
||||
|
||||
const existingTimer = sessionPrefetchTimersRef.current.get(sessionId);
|
||||
if (existingTimer !== undefined) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
sessionPrefetchTimersRef.current.delete(sessionId);
|
||||
sessionPrefetchQueueRef.current.push(sessionId);
|
||||
pumpSessionPrefetchQueue();
|
||||
}, SESSION_PREFETCH_HOVER_DELAY_MS);
|
||||
sessionPrefetchTimersRef.current.set(sessionId, timer);
|
||||
}, [currentSessionId, pumpSessionPrefetchQueue]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || sortedSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
||||
if (currentIndex < 0) {
|
||||
return;
|
||||
}
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
|
||||
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const normalizedProjects = projects
|
||||
@@ -1094,7 +1174,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
|
||||
directories.forEach((directory) => {
|
||||
const known = directoryStatus.get(directory);
|
||||
const known = directoryStatusRef.current.get(directory);
|
||||
if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) {
|
||||
return;
|
||||
}
|
||||
@@ -1147,13 +1227,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
checkingDirectories.current.delete(directory);
|
||||
});
|
||||
});
|
||||
}, [sortedSessions, projects, directoryStatus]);
|
||||
}, [sortedSessions, projects]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const prefetchTimers = sessionPrefetchTimersRef.current;
|
||||
return () => {
|
||||
if (copyTimeout.current) {
|
||||
clearTimeout(copyTimeout.current);
|
||||
}
|
||||
prefetchTimers.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
prefetchTimers.clear();
|
||||
sessionPrefetchQueueRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -2035,8 +2121,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setIsProjectRenameInline(false);
|
||||
}, [activeProjectForHeader, projectRenameDraft, renameProject]);
|
||||
|
||||
const headerActionButtonClass =
|
||||
const desktopHeaderActionButtonClass =
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||
const mobileHeaderActionButtonClass =
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md leading-none text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
|
||||
const headerActionIconClass = 'h-4.5 w-4.5';
|
||||
const addProjectButtonClass = cn(
|
||||
'inline-flex items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
mobileVariant
|
||||
? 'h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50'
|
||||
: 'h-8 w-8 text-foreground hover:bg-interactive-hover',
|
||||
!isDesktopShellRuntime && 'bg-transparent hover:bg-sidebar/40',
|
||||
);
|
||||
|
||||
// Track when project sticky headers become "stuck"
|
||||
React.useEffect(() => {
|
||||
@@ -2508,7 +2605,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
removeSessionFromFolder,
|
||||
createFolderAndStartRename,
|
||||
notifyOnSubtasks,
|
||||
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2626,7 +2722,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
depth={depth}
|
||||
onNewSession={() => {
|
||||
if (projectId && projectId !== activeProjectId) {
|
||||
setActiveProject(projectId);
|
||||
setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
@@ -2697,7 +2793,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<div className="oc-group">
|
||||
<div
|
||||
className={cn(
|
||||
"group/gh flex items-center justify-between gap-2 py-1 min-w-0 rounded-sm",
|
||||
"group/gh relative flex items-center justify-between gap-1 py-1 min-w-0 rounded-sm",
|
||||
!hideGroupLabel && "hover:bg-interactive-hover/50 cursor-pointer"
|
||||
)}
|
||||
onClick={!hideGroupLabel ? () => {
|
||||
@@ -2730,12 +2826,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined}
|
||||
>
|
||||
{!hideGroupLabel ? (
|
||||
<div className="min-w-0 flex items-center gap-1.5 pl-1.5">
|
||||
<div className={cn(
|
||||
"min-w-0 flex items-center gap-1.5 pl-1.5 transition-[padding]",
|
||||
mobileVariant
|
||||
? (!group.isMain && group.worktree ? "pr-14" : "pr-7")
|
||||
: (!group.isMain && group.worktree
|
||||
? "group-hover/gh:pr-14 group-focus-within/gh:pr-14"
|
||||
: "group-hover/gh:pr-7 group-focus-within/gh:pr-7"),
|
||||
)}>
|
||||
{!group.isMain || isGitProject ? (
|
||||
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<div className="min-w-0 flex flex-col justify-center">
|
||||
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
||||
<p className={cn('text-[14px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{group.label}
|
||||
</p>
|
||||
{showBranchSubtitle ? (
|
||||
@@ -2752,60 +2855,67 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</div>
|
||||
) : <div />}
|
||||
{group.directory ? (
|
||||
<div className="flex items-center gap-1 px-0.5">
|
||||
<>
|
||||
{!group.isMain && group.worktree ? (
|
||||
<div className={cn(
|
||||
'absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity',
|
||||
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100',
|
||||
)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allGroupSessions,
|
||||
mode: 'worktree',
|
||||
worktree: group.worktree,
|
||||
});
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete ${group.label}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>Delete worktree</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn(
|
||||
'absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity',
|
||||
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100',
|
||||
)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allGroupSessions,
|
||||
mode: 'worktree',
|
||||
worktree: group.worktree,
|
||||
});
|
||||
if (projectId && projectId !== activeProjectId) {
|
||||
setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
openNewSessionDraft({ directoryOverride: group.directory });
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100',
|
||||
)}
|
||||
aria-label={`Delete ${group.label}`}
|
||||
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={`New session in ${group.label}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>Delete worktree</p>
|
||||
<p>New session</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (projectId && projectId !== activeProjectId) {
|
||||
setActiveProject(projectId);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
openNewSessionDraft({ directoryOverride: group.directory });
|
||||
}}
|
||||
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={`New session in ${group.label}`}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>New session</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{!isCollapsed ? (
|
||||
@@ -2857,7 +2967,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
renderSessionNode,
|
||||
toggleGroupSessionLimit,
|
||||
activeProjectId,
|
||||
setActiveProject,
|
||||
setActiveProjectIdOnly,
|
||||
setActiveMainTab,
|
||||
mobileVariant,
|
||||
setSessionSwitcherOpen,
|
||||
@@ -2873,7 +2983,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
renamingFolderId,
|
||||
renameFolderDraft,
|
||||
pinnedSessionIds,
|
||||
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -2893,7 +3002,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col text-foreground overflow-x-hidden',
|
||||
mobileVariant ? '' : 'bg-sidebar',
|
||||
mobileVariant ? '' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{!hideDirectoryControls && (
|
||||
@@ -2934,7 +3043,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => setActiveProject(project.id)}
|
||||
onClick={() => setActiveProjectIdOnly(project.id)}
|
||||
className={cn('truncate', project.id === activeProjectId && 'text-primary')}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
@@ -3009,14 +3118,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenDirectoryDialog}
|
||||
className={cn(
|
||||
'inline-flex h-8 w-8 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',
|
||||
!isDesktopShellRuntime && 'bg-sidebar/60 hover:bg-sidebar',
|
||||
)}
|
||||
className={addProjectButtonClass}
|
||||
aria-label="Add project"
|
||||
title="Add project"
|
||||
>
|
||||
<RiFolderAddLine className="h-4.5 w-4.5" />
|
||||
<RiFolderAddLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -3035,7 +3141,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return;
|
||||
}
|
||||
if (activeProjectForHeader.id !== activeProjectId) {
|
||||
setActiveProject(activeProjectForHeader.id);
|
||||
setActiveProjectIdOnly(activeProjectForHeader.id);
|
||||
}
|
||||
const newWorktreePath = await createWorktreeOnly();
|
||||
if (!newWorktreePath) {
|
||||
@@ -3050,7 +3156,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New worktree"
|
||||
>
|
||||
<RiNodeTree className="h-4.5 w-4.5" />
|
||||
<RiNodeTree className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New worktree</p></TooltipContent>
|
||||
@@ -3063,7 +3169,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New from issue"
|
||||
>
|
||||
<RiGithubLine className="h-4.5 w-4.5" />
|
||||
<RiGithubLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New from issue</p></TooltipContent>
|
||||
@@ -3076,7 +3182,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New from PR"
|
||||
>
|
||||
<RiGitPullRequestLine className="h-4.5 w-4.5" />
|
||||
<RiGitPullRequestLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New from PR</p></TooltipContent>
|
||||
@@ -3089,7 +3195,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New multi-run"
|
||||
>
|
||||
<ArrowsMerge className="h-4.5 w-4.5" />
|
||||
<ArrowsMerge className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
|
||||
@@ -3105,7 +3211,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Manage branches"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4.5 w-4.5" />
|
||||
<RiGitRepositoryLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Manage branches</p></TooltipContent>
|
||||
@@ -3120,7 +3226,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Project notes and todos"
|
||||
>
|
||||
<RiStickyNoteLine className="h-4.5 w-4.5" />
|
||||
<RiStickyNoteLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
|
||||
@@ -3135,7 +3241,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Project notes and todos"
|
||||
>
|
||||
<RiStickyNoteLine className="h-4.5 w-4.5" />
|
||||
<RiStickyNoteLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -3225,7 +3331,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
onHoverChange={(hovered) => setHoveredProjectId(hovered ? projectKey : null)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
setActiveProjectIdOnly(projectKey);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
@@ -3235,7 +3341,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
setActiveProjectIdOnly(projectKey);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
@@ -3245,19 +3351,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}}
|
||||
onNewSessionFromGitHubIssue={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
setActiveProjectIdOnly(projectKey);
|
||||
}
|
||||
setIssuePickerOpen(true);
|
||||
}}
|
||||
onNewSessionFromGitHubPR={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
setActiveProjectIdOnly(projectKey);
|
||||
}
|
||||
setPullRequestPickerOpen(true);
|
||||
}}
|
||||
onOpenMultiRunLauncher={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
setActiveProjectIdOnly(projectKey);
|
||||
}
|
||||
openMultiRunLauncher();
|
||||
}}
|
||||
@@ -3282,19 +3388,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={(event) => {
|
||||
setActiveDraggedGroupId(String(event.active.id));
|
||||
const width = (event.active.rect.current.initial?.width ?? null);
|
||||
setActiveDraggedGroupWidth(typeof width === 'number' ? width : null);
|
||||
}}
|
||||
onDragCancel={() => {
|
||||
setActiveDraggedGroupId(null);
|
||||
setActiveDraggedGroupWidth(null);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
setActiveDraggedGroupId(null);
|
||||
setActiveDraggedGroupWidth(null);
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
@@ -3324,18 +3419,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay modifiers={[centerDragOverlayUnderPointer]} dropAnimation={null}>
|
||||
{activeDraggedGroupId ? (
|
||||
(() => {
|
||||
const dragGroup = orderedGroups.find((group) => group.id === activeDraggedGroupId);
|
||||
if (!dragGroup) {
|
||||
return null;
|
||||
}
|
||||
const showBranchIcon = !dragGroup.isMain || Boolean(isRepo);
|
||||
return <GroupDragOverlay label={dragGroup.label} showBranchIcon={showBranchIcon} width={activeDraggedGroupWidth ?? undefined} />;
|
||||
})()
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user