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:
Bohdan Triapitsyn
2026-02-16 14:15:19 +02:00
committed by GitHub
parent 12606b9e53
commit 47c943b487
42 changed files with 4874 additions and 1163 deletions
@@ -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"
/>