feat(ui): redesign workspace shell with context panel, tabbed sidebars, and faster diff UX (#433)
* feat: tabbed right sidebar, context panel, floating diff comments * fix: auto-close left sidebar when context panel opens - Increase default context panel width from 520 to 600 pixels - Increase sidebar minimum width from 200 to 300 pixels - Replace collapsible component with custom button in diff view * refactoring: rework sidebars, tabs, and file tree layout - Rewrite AnimatedTabs as segment-style with sliding indicator - Upgrade SidebarFilesTree to match FilesView features (context menus, git status, file icons, CRUD dialogs, fuzzy search ranking) - Restructure FilesView header: tabs row + actions row, remove breadcrumbs - Show relative path in context panel header, track active tab - Allow left sidebar to stay open alongside context panel - Hide diff/files tabs from header on desktop (mobile-only) - Move chevron after group name in session sidebar - Compact tab heights in right sidebar and git view - Size PreviewToggleButton to match other action buttons - Remove directory loading spinner from folder icons * feat: add project icon and color customization - Enable users to assign custom icons to projects - Allow users to choose accent colors for projects - Stabilize repo status UI during project switching * feat: add scroll fade indicators to editor tabs * style: reduce spacing and icon sizes in header * style: adjust tab component padding from uniform to vertical-horizontal * feat: Add session state indicators to project tabs * feat: Enhance session status handling and improve UI responsiveness * fix: preserve upstream tracking on branch rename * fix: improve initial remote selection for pull requests - Uses saved remote name from previous session when available - Selects remote based on tracking branch when possible - Falls back to origin or first available remote * perf(diff): faster highlight, stable stacked scroll - split/unified Pierre worker pools; prefer shiki-wasm - align diff CSS line-height; disable scroll anchoring; drop WebKit compositing hacks - harden stacked pin/align (cancel on user scroll/input); prevent overscroll - make overlay scrollbar MutationObserver optional; disable for diff container * feat: handle binary files in diff view * fix: adjust project tabs layout and drag regions * style: update drag overlay visual styling * feat: enable number keys to switch projects in the sidebar * fix: recognize octet-stream as text-based MIME type * feat: add keyboard navigation to context panel * feat: add session pinning to sidebar - Pin important sessions to keep them at the top - Pinned sessions persist across browser sessions * refactor: move context usage display from chat input to header
This commit is contained in:
committed by
GitHub
parent
12606b9e53
commit
47c943b487
@@ -17,10 +17,17 @@ import {
|
||||
RiLoader4Line,
|
||||
RiPencilLine,
|
||||
RiSearchLine,
|
||||
RiSplitCellsHorizontal,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi';
|
||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
export interface BranchPickerProject {
|
||||
id: string;
|
||||
@@ -38,13 +45,35 @@ interface BranchPickerDialogProps {
|
||||
const displayProjectName = (project: BranchPickerProject): string =>
|
||||
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
|
||||
|
||||
const normalizeBranchName = (value: string | null | undefined): string => {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/^remotes\//, '');
|
||||
};
|
||||
|
||||
const normalizePath = (value: string | null | undefined): string => {
|
||||
const raw = String(value || '').trim().replace(/\\/g, '/');
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (raw === '/') {
|
||||
return '/';
|
||||
}
|
||||
return raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
|
||||
};
|
||||
|
||||
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [branches, setBranches] = React.useState<GitBranch | null>(null);
|
||||
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
|
||||
const [rootBranchName, setRootBranchName] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [creatingWorktreeBranch, setCreatingWorktreeBranch] = React.useState<string | null>(null);
|
||||
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
|
||||
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
|
||||
@@ -57,16 +86,19 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [b, w] = await Promise.all([
|
||||
const [b, w, rootBranch] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
git.worktree.list(project.path),
|
||||
getRootBranch(project.path).catch(() => null),
|
||||
]);
|
||||
setBranches(b);
|
||||
setWorktrees(w);
|
||||
setRootBranchName(rootBranch);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setRootBranchName(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -80,6 +112,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
setCreatingWorktreeBranch(null);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
@@ -161,7 +194,105 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
}
|
||||
}, [project, refresh, forceDeleteBranch]);
|
||||
|
||||
const worktreeBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean));
|
||||
const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingWorktreeBranch(branchName);
|
||||
try {
|
||||
const setupCommands = await getWorktreeSetupCommands({
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
});
|
||||
await createWorktreeWithDefaults(
|
||||
{
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
},
|
||||
{
|
||||
preferredName: branchName,
|
||||
mode: 'existing',
|
||||
existingBranch: branchName,
|
||||
branchName,
|
||||
worktreeName: branchName,
|
||||
setupCommands,
|
||||
}
|
||||
);
|
||||
await refresh();
|
||||
toast.success('Worktree created', { description: branchName });
|
||||
} catch (err) {
|
||||
toast.error('Failed to create worktree', {
|
||||
description: err instanceof Error ? err.message : 'Create worktree failed',
|
||||
});
|
||||
} finally {
|
||||
setCreatingWorktreeBranch(null);
|
||||
}
|
||||
}, [project, refresh]);
|
||||
|
||||
const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => {
|
||||
if (!project || !worktree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedWorktreePath = normalizePath(worktree.path);
|
||||
const directSessions = sessions.filter((session) => {
|
||||
const sessionPath = normalizePath(session.directory ?? null);
|
||||
return Boolean(sessionPath) && sessionPath === normalizedWorktreePath;
|
||||
});
|
||||
const directSessionIds = new Set(directSessions.map((session) => session.id));
|
||||
|
||||
const findSubsessions = (parentIds: Set<string>): typeof sessions => {
|
||||
const subsessions = sessions.filter((session) => {
|
||||
const parentID = (session as { parentID?: string | null }).parentID;
|
||||
if (!parentID) {
|
||||
return false;
|
||||
}
|
||||
return parentIds.has(parentID);
|
||||
});
|
||||
if (subsessions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const subsessionIds = new Set(subsessions.map((session) => session.id));
|
||||
return [...subsessions, ...findSubsessions(subsessionIds)];
|
||||
};
|
||||
|
||||
const allSubsessions = findSubsessions(directSessionIds);
|
||||
const seenIds = new Set<string>();
|
||||
const allSessions = [...directSessions, ...allSubsessions].filter((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(session.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const normalizedBranch = normalizeBranchName(worktree.branch);
|
||||
const worktreeMetadata: WorktreeMetadata = {
|
||||
source: 'sdk',
|
||||
name: worktree.name,
|
||||
path: worktree.path,
|
||||
projectDirectory: project.path,
|
||||
branch: normalizedBranch,
|
||||
label: normalizedBranch || worktree.name,
|
||||
};
|
||||
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allSessions,
|
||||
mode: 'worktree',
|
||||
worktree: worktreeMetadata,
|
||||
});
|
||||
}, [project, sessions]);
|
||||
|
||||
const worktreeByBranch = new Map<string, GitWorktreeInfo>();
|
||||
for (const worktree of worktrees) {
|
||||
const branchName = normalizeBranchName(worktree.branch);
|
||||
if (branchName && !worktreeByBranch.has(branchName)) {
|
||||
worktreeByBranch.set(branchName, worktree);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRootBranch = normalizeBranchName(rootBranchName);
|
||||
const allBranches = branches?.all || [];
|
||||
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
|
||||
@@ -204,16 +335,34 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
) : (
|
||||
localBranches.map((branchName) => {
|
||||
const details = branches?.branches[branchName];
|
||||
const normalizedBranchName = normalizeBranchName(branchName);
|
||||
const isCurrent = Boolean(details?.current);
|
||||
const isDeleting = deletingBranch === branchName;
|
||||
const isRenaming = renamingBranchKey === branchName;
|
||||
const hasAttachedWorktree = worktreeBranches.has(branchName);
|
||||
const attachedWorktree = worktreeByBranch.get(normalizedBranchName) ?? null;
|
||||
const hasAttachedWorktree = Boolean(attachedWorktree);
|
||||
const isProjectRootBranch = Boolean(
|
||||
normalizedBranchName &&
|
||||
normalizedRootBranch &&
|
||||
normalizedBranchName === normalizedRootBranch
|
||||
);
|
||||
const isEditing = editingBranch === branchName;
|
||||
const isConfirming = confirmingDelete === branchName;
|
||||
const isForceDelete = forceDeleteBranch === branchName;
|
||||
const isCreatingWorktree = creatingWorktreeBranch === branchName;
|
||||
|
||||
const disableDelete = Boolean(isCurrent || hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
const disableRename = Boolean(hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
const disableCreateWorktree = Boolean(
|
||||
hasAttachedWorktree || isCreatingWorktree || isDeleting || isRenaming || isEditing
|
||||
);
|
||||
const disableDelete = Boolean(
|
||||
isCurrent || isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableRename = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableWorktreeDelete = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch || !attachedWorktree
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -258,7 +407,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
current
|
||||
HEAD
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -284,6 +433,27 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
|
||||
{!isEditing && !isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateWorktreeForBranch(branchName)}
|
||||
disabled={disableCreateWorktree}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label="Create worktree"
|
||||
>
|
||||
{isCreatingWorktree ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiSplitCellsHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? 'Worktree already exists' : 'Create worktree'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -297,7 +467,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? 'Rename (remove worktree first)' : 'Rename'}
|
||||
{isProjectRootBranch ? 'Rename disabled for root branch' : 'Rename'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -305,10 +475,16 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(branchName)}
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
if (hasAttachedWorktree) {
|
||||
handleRemoveWorktree(attachedWorktree);
|
||||
return;
|
||||
}
|
||||
setConfirmingDelete(branchName);
|
||||
}}
|
||||
disabled={hasAttachedWorktree ? disableWorktreeDelete : disableDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label="Delete"
|
||||
aria-label={hasAttachedWorktree ? 'Delete worktree' : 'Delete'}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
@@ -318,11 +494,15 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{isCurrent
|
||||
? 'Delete (current branch)'
|
||||
: hasAttachedWorktree
|
||||
? 'Delete (remove worktree first)'
|
||||
: 'Delete'}
|
||||
{hasAttachedWorktree
|
||||
? isProjectRootBranch
|
||||
? 'Delete worktree (root branch protected)'
|
||||
: 'Delete worktree'
|
||||
: isCurrent
|
||||
? 'Delete (current branch)'
|
||||
: isProjectRootBranch
|
||||
? 'Delete disabled for root branch'
|
||||
: 'Delete'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -354,7 +534,7 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEditing && isConfirming ? (
|
||||
{!isEditing && isConfirming && !hasAttachedWorktree ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className={cn(
|
||||
'text-xs mr-1',
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
RiFolderAddLine,
|
||||
RiGitBranchLine,
|
||||
RiGitPullRequestLine,
|
||||
RiGitRepositoryLine,
|
||||
RiStickyNoteLine,
|
||||
RiLinkUnlinkM,
|
||||
|
||||
@@ -50,8 +51,10 @@ import {
|
||||
|
||||
RiMore2Line,
|
||||
RiPencilAiLine,
|
||||
RiPushpinLine,
|
||||
RiShare2Line,
|
||||
RiShieldLine,
|
||||
RiUnpinLine,
|
||||
} from '@remixicon/react';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
@@ -74,6 +77,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
||||
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
||||
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
|
||||
import { BranchPickerDialog } from './BranchPickerDialog';
|
||||
|
||||
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
||||
|
||||
@@ -86,6 +90,7 @@ const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||
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 formatDateLabel = (value: string | number) => {
|
||||
const targetDate = new Date(value);
|
||||
@@ -146,6 +151,28 @@ const toFiniteNumber = (value: unknown): number | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getSessionCreatedAt = (session: Session): number => {
|
||||
return toFiniteNumber(session.time?.created) ?? 0;
|
||||
};
|
||||
|
||||
const getSessionUpdatedAt = (session: Session): number => {
|
||||
return toFiniteNumber(session.time?.updated) ?? 0;
|
||||
};
|
||||
|
||||
const compareSessionsByPinnedAndTime = (a: Session, b: Session, pinnedSessionIds: Set<string>): number => {
|
||||
const aPinned = pinnedSessionIds.has(a.id);
|
||||
const bPinned = pinnedSessionIds.has(b.id);
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
if (aPinned && bPinned) {
|
||||
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
||||
}
|
||||
|
||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||
};
|
||||
|
||||
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
|
||||
if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) {
|
||||
return transform;
|
||||
@@ -525,6 +552,7 @@ interface SessionSidebarProps {
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
allowReselect?: boolean;
|
||||
hideDirectoryControls?: boolean;
|
||||
hideProjectSelector?: boolean;
|
||||
showOnlyMainWorkspace?: boolean;
|
||||
}
|
||||
|
||||
@@ -533,6 +561,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
onSessionSelected,
|
||||
allowReselect = false,
|
||||
hideDirectoryControls = false,
|
||||
hideProjectSelector = false,
|
||||
showOnlyMainWorkspace = false,
|
||||
}) => {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
@@ -554,9 +583,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
|
||||
const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false);
|
||||
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
|
||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||
const [pinnedSessionIds, setPinnedSessionIds] = React.useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return new Set();
|
||||
}
|
||||
const parsed = JSON.parse(raw) as string[];
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
||||
try {
|
||||
const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
|
||||
@@ -722,10 +764,46 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
} catch { /* ignored */ }
|
||||
}, [safeStorage]);
|
||||
|
||||
const sortedSessions = React.useMemo(() => {
|
||||
return [...sessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
|
||||
React.useEffect(() => {
|
||||
const existingSessionIds = new Set(sessions.map((session) => session.id));
|
||||
setPinnedSessionIds((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((id) => {
|
||||
if (existingSessionIds.has(id)) {
|
||||
next.add(id);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
safeStorage.setItem(SESSION_PINNED_STORAGE_KEY, JSON.stringify(Array.from(pinnedSessionIds)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [pinnedSessionIds, safeStorage]);
|
||||
|
||||
const togglePinnedSession = React.useCallback((sessionId: string) => {
|
||||
setPinnedSessionIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId);
|
||||
} else {
|
||||
next.add(sessionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sortedSessions = React.useMemo(() => {
|
||||
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||
}, [sessions, pinnedSessionIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const normalizedProjects = projects
|
||||
@@ -778,9 +856,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
collection.push(session);
|
||||
map.set(parentID, collection);
|
||||
});
|
||||
map.forEach((list) => list.sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)));
|
||||
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
|
||||
return map;
|
||||
}, [sortedSessions]);
|
||||
}, [sortedSessions, pinnedSessionIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const directories = new Set<string>();
|
||||
@@ -1110,7 +1188,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
projectIsRepo: boolean,
|
||||
) => {
|
||||
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
||||
const sortedProjectSessions = [...projectSessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
|
||||
const sortedProjectSessions = [...projectSessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||
|
||||
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
@@ -1123,7 +1201,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
collection.push(session);
|
||||
childrenMap.set(parentID, collection);
|
||||
});
|
||||
childrenMap.forEach((list) => list.sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0)));
|
||||
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
|
||||
|
||||
// Build worktree lookup map
|
||||
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
||||
@@ -1245,7 +1323,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
return groups;
|
||||
},
|
||||
[homeDirectory, worktreeMetadata]
|
||||
[homeDirectory, worktreeMetadata, pinnedSessionIds]
|
||||
);
|
||||
|
||||
const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
|
||||
@@ -1390,16 +1468,25 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
[availableWorktreesByProject, getSessionsByDirectory, sessionsByDirectory, isVSCode],
|
||||
);
|
||||
|
||||
// Keep last-known repo status to avoid UI jiggling during project switch
|
||||
const lastRepoStatusRef = React.useRef(false);
|
||||
if (activeProjectId && projectRepoStatus.has(activeProjectId)) {
|
||||
lastRepoStatusRef.current = Boolean(projectRepoStatus.get(activeProjectId));
|
||||
}
|
||||
|
||||
const projectSections = React.useMemo(() => {
|
||||
return normalizedProjects.map((project) => {
|
||||
const projectSessions = getSessionsForProject(project);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
const isRepo = projectRepoStatus.has(project.id)
|
||||
? Boolean(projectRepoStatus.get(project.id))
|
||||
: lastRepoStatusRef.current;
|
||||
const groups = buildGroupedSessions(
|
||||
projectSessions,
|
||||
project.normalizedPath,
|
||||
worktreesForProject,
|
||||
projectRootBranches.get(project.id) ?? null,
|
||||
Boolean(projectRepoStatus.get(project.id)),
|
||||
isRepo,
|
||||
);
|
||||
return {
|
||||
project,
|
||||
@@ -1429,11 +1516,26 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
: null),
|
||||
[activeProjectForHeader],
|
||||
);
|
||||
const branchPickerProject = React.useMemo(() => {
|
||||
if (!activeProjectForHeader) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: activeProjectForHeader.id,
|
||||
path: activeProjectForHeader.path,
|
||||
normalizedPath: activeProjectForHeader.normalizedPath,
|
||||
label: activeProjectForHeader.label,
|
||||
};
|
||||
}, [activeProjectForHeader]);
|
||||
|
||||
const activeProjectIsRepo = React.useMemo(
|
||||
() => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false),
|
||||
[activeProjectForHeader, projectRepoStatus],
|
||||
);
|
||||
// Only flip to false once the new project's status is actually resolved (present in map)
|
||||
const stableActiveProjectIsRepo = activeProjectForHeader && projectRepoStatus.has(activeProjectForHeader.id)
|
||||
? activeProjectIsRepo
|
||||
: lastRepoStatusRef.current;
|
||||
const reserveHeaderActionsSpace = Boolean(activeProjectForHeader);
|
||||
const useMobileNotesPanel = mobileVariant || deviceInfo.isMobile;
|
||||
|
||||
@@ -1690,6 +1792,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const isActive = currentSessionId === session.id;
|
||||
const sessionTitle = session.title || 'Untitled Session';
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||||
const isExpanded = expandedParents.has(session.id);
|
||||
const needsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true;
|
||||
const sessionSummary = session.summary as
|
||||
@@ -1834,8 +1937,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
>
|
||||
{}
|
||||
<div className="flex w-full items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
{showStatusMarker ? (
|
||||
<div className="flex w-full items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
{showStatusMarker ? (
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
{isStreaming ? (
|
||||
<GridLoader size="xs" className="text-primary" />
|
||||
@@ -1856,6 +1959,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{isPinnedSession ? (
|
||||
<RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" />
|
||||
) : null}
|
||||
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
||||
{sessionTitle}
|
||||
</div>
|
||||
@@ -1955,6 +2061,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<RiPencilAiLine className="mr-1 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
|
||||
{isPinnedSession ? (
|
||||
<RiUnpinLine className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<RiPushpinLine className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{isPinnedSession ? 'Unpin session' : 'Pin session'}
|
||||
</DropdownMenuItem>
|
||||
{!session.share ? (
|
||||
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
|
||||
<RiShare2Line className="mr-1 h-4 w-4" />
|
||||
@@ -2023,6 +2137,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
toggleParent,
|
||||
handleSessionSelect,
|
||||
handleSessionDoubleClick,
|
||||
pinnedSessionIds,
|
||||
togglePinnedSession,
|
||||
handleShareSession,
|
||||
handleCopyShareUrl,
|
||||
handleUnshareSession,
|
||||
@@ -2056,7 +2172,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
};
|
||||
const allGroupSessions = collectGroupSessions(group.sessions);
|
||||
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
|
||||
const isGitProject = Boolean(projectId && projectRepoStatus.get(projectId));
|
||||
const isGitProject = projectId && projectRepoStatus.has(projectId)
|
||||
? Boolean(projectRepoStatus.get(projectId))
|
||||
: lastRepoStatusRef.current;
|
||||
const showBranchSubtitle = !group.isMain && isBranchDifferentFromLabel(group.branch, group.label);
|
||||
const isActiveGroup = Boolean(
|
||||
normalizedGroupDirectory
|
||||
@@ -2136,12 +2254,7 @@ 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 px-0">
|
||||
{isCollapsed ? (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex items-center gap-1.5 pl-1.5">
|
||||
{!group.isMain || isGitProject ? (
|
||||
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
@@ -2155,6 +2268,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
) : <div />}
|
||||
{group.directory ? (
|
||||
@@ -2282,7 +2400,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
>
|
||||
{!hideDirectoryControls && (
|
||||
<div className="select-none pl-3.5 pr-2 py-1.5 flex-shrink-0 border-b border-border/60">
|
||||
<div className={cn('select-none pl-3.5 pr-2 flex-shrink-0 border-b border-border/60', hideProjectSelector ? 'py-1' : 'py-1.5')}>
|
||||
{!hideProjectSelector && (
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
@@ -2403,11 +2522,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<RiFolderAddLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{reserveHeaderActionsSpace ? (
|
||||
<div className="mt-1 h-8 pl-1">
|
||||
<div className="mt-1 -ml-1 flex h-8 items-center">
|
||||
{activeProjectForHeader ? (
|
||||
<div className="inline-flex h-8 items-center gap-1.5 rounded-md pl-0 pr-1">
|
||||
{activeProjectIsRepo ? (
|
||||
<div className="flex h-full items-center gap-1.5 rounded-md pl-0 pr-1">
|
||||
{stableActiveProjectIsRepo ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2479,6 +2599,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
{stableActiveProjectIsRepo && branchPickerProject ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsBranchPickerOpen(true)}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Manage branches"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Manage branches</p></TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{useMobileNotesPanel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2512,7 +2647,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<DropdownMenuContent align="start" className="w-[340px] p-0">
|
||||
<ProjectNotesTodoPanel
|
||||
projectRef={activeProjectRefForHeader}
|
||||
canCreateWorktree={activeProjectIsRepo}
|
||||
canCreateWorktree={stableActiveProjectIsRepo}
|
||||
onActionComplete={() => setProjectNotesPanelOpen(false)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
@@ -2741,6 +2876,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<BranchPickerDialog
|
||||
open={isBranchPickerOpen}
|
||||
onOpenChange={setIsBranchPickerOpen}
|
||||
project={branchPickerProject}
|
||||
/>
|
||||
|
||||
{useMobileNotesPanel ? (
|
||||
<MobileOverlayPanel
|
||||
open={projectNotesPanelOpen}
|
||||
@@ -2749,7 +2890,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
>
|
||||
<ProjectNotesTodoPanel
|
||||
projectRef={activeProjectRefForHeader}
|
||||
canCreateWorktree={activeProjectIsRepo}
|
||||
canCreateWorktree={stableActiveProjectIsRepo}
|
||||
onActionComplete={() => setProjectNotesPanelOpen(false)}
|
||||
className="p-0"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user