refactor: modularize session sidebar and add GitHub PR tracking (#610)

* feat: switch sessions sidebar to global paginated loading with archived flow

Load sessions via global endpoint with progressive 500-item pagination and legacy fallback
Add dedicated archived sidebar section for archived and unassigned sessions
Change remove behavior to archive outside archived and hard-delete inside archived

* feat: improve archived sessions UX and folder persistence

Archive sessions on worktree removal while keeping worktree deletion
Streamline archived sidebar actions, icons, metadata, and tooltips
Persist session folders to ~/.config/openchamber/sessions-directories.json with startup hydration

* fix: align archived session actions and clean empty archived folders

Apply archived dropdown behavior consistently for folder-contained sessions
Remove archived-only folder actions while keeping standard folder behavior elsewhere
Auto-prune empty archived folders during session cleanup and persistence sync

* refactor: modularize session sidebar and stabilize behavior

Split monolithic sidebar logic into focused hooks and components
Kept session, archive, folder, and project interactions working with cleaner state persistence
Added sidebar DOCUMENTATION.md summarizing file roles and refactor outcomes

* fix: improve fork PR detection and smart remote tracking

Added centralized PR status store for shared polling and refresh
Auto-selects the remote that has an existing PR when current remote has none
Stops periodic polling for closed or merged PRs to reduce unnecessary requests

* fix: make chat and toast corners follow active theme radius

Toast corners now use theme radius tokens instead of hardcoded rounding
User message bubble now uses theme-configured max radius with preserved tail corner
Square-corner themes now consistently affect both toasts and chat bubbles

* feat: show live PR status across git view and session sidebar

Added a shared GitHub PR status store with adaptive background polling and terminal-state pause
Improved fork remote detection and auto-selection so existing PRs are found more reliably
Updated session group headers to show clickable PR number with branch and state-colored branch icon

* feat: centralize GitHub PR tracking and enrich session sidebar PR details

Moved PR status polling to a single global pipeline keyed by directory and branch
Improved fork-aware PR resolution and reduced duplicate GitHub status fetches across views
Added richer session sidebar PR display with clickable number, state-aware styling, and structured tooltip details

* fix: adjust PR indicator icon vertical alignment

Fine-tuned PR indicator icon vertical alignment in session sidebar
Reduced icon translate-y from 2px to 0.5px for better visual balance

* feat: improve session sidebar status display

* feat: enhance session display logic for minimal mode and improve dropdown menu accessibility

* feat: refactor session row to include tooltip for minimal display mode
This commit is contained in:
Bohdan Triapitsyn
2026-03-06 17:22:59 +02:00
committed by GitHub
parent d54e1199df
commit a7f11121e8
47 changed files with 6538 additions and 3772 deletions
@@ -0,0 +1,580 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import {
RiAddLine,
RiArchiveLine,
RiArrowDownSLine,
RiArrowLeftLongLine,
RiArrowRightSLine,
RiDeleteBinLine,
RiGitBranchLine,
} from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { sessionEvents } from '@/lib/sessionEvents';
import type { MainTab } from '@/stores/useUIStore';
import { SessionFolderItem } from '../SessionFolderItem';
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
type DeleteFolderConfirm = {
scopeKey: string;
folderId: string;
folderName: string;
subFolderCount: number;
sessionCount: number;
} | null;
type Props = {
group: SessionGroup;
groupKey: string;
projectId?: string | null;
hideGroupLabel?: boolean;
hasSessionSearchQuery: boolean;
normalizedSessionSearchQuery: string;
groupSearchDataByGroup: WeakMap<SessionGroup, GroupSearchData>;
expandedSessionGroups: Set<string>;
collapsedGroups: Set<string>;
hideDirectoryControls: boolean;
getFoldersForScope: (scopeKey: string) => SessionFolder[];
collapsedFolderIds: Set<string>;
toggleFolderCollapse: (folderId: string) => void;
renameFolder: (scopeKey: string, folderId: string, name: string) => void;
deleteFolder: (scopeKey: string, folderId: string) => void;
showDeletionDialog: boolean;
setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>;
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode;
currentSessionDirectory: string | null;
projectRepoStatus: Map<string, boolean | null>;
lastRepoStatus: boolean;
toggleGroupSessionLimit: (groupKey: string) => void;
mobileVariant: boolean;
activeProjectId: string | null;
setActiveProjectIdOnly: (id: string) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
openNewSessionDraft: (options?: { directoryOverride?: string | null; targetFolderId?: string }) => void;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
renamingFolderId: string | null;
renameFolderDraft: string;
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
pinnedSessionIds: Set<string>;
prVisualStateByDirectoryBranch: Map<string, {
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
number: number;
url: string | null;
state: 'open' | 'closed' | 'merged';
draft: boolean;
title: string | null;
base: string | null;
head: string | null;
checks: {
state: 'success' | 'failure' | 'pending' | 'unknown';
total: number;
success: number;
failure: number;
pending: number;
} | null;
canMerge: boolean | null;
mergeableState: string | null;
repo: {
owner: string;
repo: string;
} | null;
}>;
onToggleCollapsedGroup: (groupKey: string) => void;
};
export function SessionGroupSection(props: Props): React.ReactNode {
const {
group,
groupKey,
projectId,
hideGroupLabel,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
groupSearchDataByGroup,
expandedSessionGroups,
collapsedGroups,
hideDirectoryControls,
getFoldersForScope,
collapsedFolderIds,
toggleFolderCollapse,
renameFolder,
deleteFolder,
showDeletionDialog,
setDeleteFolderConfirm,
renderSessionNode,
currentSessionDirectory,
projectRepoStatus,
lastRepoStatus,
toggleGroupSessionLimit,
mobileVariant,
activeProjectId,
setActiveProjectIdOnly,
setActiveMainTab,
setSessionSwitcherOpen,
openNewSessionDraft,
addSessionToFolder,
createFolderAndStartRename,
renamingFolderId,
renameFolderDraft,
setRenameFolderDraft,
setRenamingFolderId,
pinnedSessionIds,
prVisualStateByDirectoryBranch,
onToggleCollapsedGroup,
} = props;
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
const isExpanded = expandedSessionGroups.has(groupKey);
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
const maxVisible = hideDirectoryControls ? 10 : 5;
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
const shouldFilterGroupContents = hasSessionSearchQuery;
const sourceGroupNodes = shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions;
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : [];
const nodeBySessionId = new Map<string, SessionNode>();
const collectNodeLookup = (nodes: SessionNode[]) => {
nodes.forEach((node) => {
nodeBySessionId.set(node.session.id, node);
if (node.children.length > 0) {
collectNodeLookup(node.children);
}
});
};
collectNodeLookup(sourceGroupNodes);
const allFoldersForGroupBase = scopeFolders.map((folder) => {
const nodes = folder.sessionIds
.map((sid) => nodeBySessionId.get(sid))
.filter((n): n is SessionNode => Boolean(n))
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds));
return { folder, nodes };
});
const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]));
const shouldKeepFolder = (folderId: string): boolean => {
const entry = folderMapById.get(folderId);
if (!entry) return false;
if (!hasSessionSearchQuery) return true;
const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery);
if (folderMatches || entry.nodes.length > 0) return true;
return allFoldersForGroupBase
.filter(({ folder }) => folder.parentId === folderId)
.some(({ folder }) => shouldKeepFolder(folder.id));
};
const allFoldersForGroup = hasSessionSearchQuery
? allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id))
: allFoldersForGroupBase;
const sessionIdsInFolders = new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds));
const ungroupedSessions = sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id));
const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId);
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
const totalSessions = ungroupedSessions.length;
const visibleSessions = group.isArchivedBucket
? ungroupedSessions
: hasSessionSearchQuery
? ungroupedSessions
: (isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible));
const remainingCount = totalSessions - visibleSessions.length;
const collectGroupSessions = (nodes: SessionNode[]): Session[] => {
const collected: Session[] = [];
const visit = (list: SessionNode[]) => {
list.forEach((node) => {
collected.push(node.session);
if (node.children.length > 0) visit(node.children);
});
};
visit(nodes);
return collected;
};
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
const isGitProject = projectId && projectRepoStatus.has(projectId)
? Boolean(projectRepoStatus.get(projectId))
: lastRepoStatus;
const isActiveGroup = Boolean(
normalizedGroupDirectory
&& currentSessionDirectory
&& normalizedGroupDirectory === currentSessionDirectory,
);
const groupDirectoryKey = normalizePath(group.directory ?? null);
const groupBranchKey = group.branch?.trim() ?? null;
const prIndicator = groupDirectoryKey && groupBranchKey
? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null)
: null;
const showInlinePrTitle = Boolean(prIndicator && group.branch);
const showBranchSubtitle = !group.isMain && (isBranchDifferentFromLabel(group.branch, group.label) || Boolean(prIndicator));
const prVisualState = prIndicator?.visualState ?? null;
const branchIconColor = prVisualState ? `var(--pr-${prVisualState})` : undefined;
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
? `${prIndicator.checks.success}/${prIndicator.checks.total} checks passed`
: null;
const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks
? [
prIndicator.checks.failure > 0 ? `${prIndicator.checks.failure} failing` : null,
prIndicator.checks.pending > 0 ? `${prIndicator.checks.pending} pending` : null,
].filter((item): item is string => Boolean(item)).join(', ')
: null;
const mergeabilityLabel = prIndicator && prIndicator.state === 'open'
? (prIndicator.canMerge === true
? 'Mergeable'
: (prIndicator.canMerge === false ? 'Conflicts or blocked' : null))
: null;
const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState
? `Merge state: ${prIndicator.mergeableState}`
: null;
const baseBranchLabel = prIndicator?.base ?? null;
const headBranchLabel = prIndicator?.head ?? null;
const handlePrLinkClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
const url = prIndicator?.url;
if (!url || typeof window === 'undefined') {
return;
}
const tauri = (window as unknown as { __TAURI__?: { shell?: { open?: (target: string) => Promise<unknown> } } }).__TAURI__;
if (tauri?.shell?.open) {
void tauri.shell.open(url).catch(() => {
window.open(url, '_blank', 'noopener,noreferrer');
});
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
};
const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => {
const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id);
const subFolderItems = directSubFolders.length > 0
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
: undefined;
return (
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
{(droppableRef, isDropTarget) => (
<SessionFolderItem
folder={folder}
sessions={nodes}
subFolderItems={subFolderItems}
isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
onToggle={() => toggleFolderCollapse(folder.id)}
onRename={(name) => {
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
}}
onDelete={() => {
if (!folderScopeKey) return;
if (!showDeletionDialog) {
deleteFolder(folderScopeKey, folder.id);
return;
}
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
const sessionCount = nodes.length;
setDeleteFolderConfirm({
scopeKey: folderScopeKey,
folderId: folder.id,
folderName: folder.name,
subFolderCount,
sessionCount,
});
}}
renderSessionNode={renderSessionNode}
groupDirectory={group.directory}
projectId={projectId}
mobileVariant={mobileVariant}
isRenaming={renamingFolderId === folder.id}
renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined}
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
onRenameSave={() => {
const trimmed = renameFolderDraft.trim();
if (trimmed && folderScopeKey) {
renameFolder(folderScopeKey, folder.id, trimmed);
}
setRenamingFolderId(null);
setRenameFolderDraft('');
}}
onRenameCancel={() => {
setRenamingFolderId(null);
setRenameFolderDraft('');
}}
droppableRef={droppableRef}
isDropTarget={isDropTarget}
depth={depth}
onNewSession={() => {
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
setActiveMainTab('chat');
if (mobileVariant) setSessionSwitcherOpen(false);
openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id });
}}
onNewSubFolder={depth === 0 ? () => {
if (!folderScopeKey) return;
createFolderAndStartRename(folderScopeKey, folder.id);
} : undefined}
hideActions={group.isArchivedBucket === true}
archivedBucket={group.isArchivedBucket === true}
/>
)}
</DroppableFolderWrapper>
);
};
const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0));
const body = (
<SessionFolderDndScope
scopeKey={folderScopeKey}
hasFolders={allFoldersForGroup.length > 0}
onSessionDroppedOnFolder={(sessionId, folderId) => {
if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId);
}}
>
{renderFolderItems()}
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))}
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
{group.isArchivedBucket ? 'No archived sessions yet.' : 'No sessions in this workspace yet.'}
</div>
) : null}
{remainingCount > 0 && !isExpanded ? (
<button
type="button"
onClick={() => toggleGroupSessionLimit(groupKey)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show {remainingCount} more {remainingCount === 1 ? 'session' : 'sessions'}
</button>
) : null}
{isExpanded && totalSessions > maxVisible ? (
<button
type="button"
onClick={() => toggleGroupSessionLimit(groupKey)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show fewer sessions
</button>
) : null}
</SessionFolderDndScope>
);
if (hideGroupLabel) {
return <div className="oc-group"><div className="oc-group-body pb-3">{body}</div></div>;
}
return (
<div className="oc-group">
<div
className={cn('group/gh relative flex items-center justify-between gap-1 py-1 min-w-0 rounded-sm', 'hover:bg-interactive-hover/50 cursor-pointer')}
onClick={() => onToggleCollapsedGroup(groupKey)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleCollapsedGroup(groupKey);
}
}}
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
>
<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.isArchivedBucket ? (
<RiArchiveLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (!group.isMain || isGitProject) ? (
showInlinePrTitle && prIndicator ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<RiGitBranchLine
className="h-3.5 w-3.5 flex-shrink-0 translate-y-[0.5px] text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
<div className="space-y-1 text-xs">
{(baseBranchLabel || headBranchLabel) ? (
<div className="text-muted-foreground truncate">
{baseBranchLabel && headBranchLabel ? (
<>
<span>{baseBranchLabel}</span>
<RiArrowLeftLongLine className="mx-0.5 inline h-3 w-3 align-[-2px]" />
<span>{headBranchLabel}</span>
</>
) : (
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
)}
</div>
) : null}
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
{(mergeabilityLabel || checksSummary) ? (
<div className="text-muted-foreground truncate">
{mergeabilityLabel ?? ''}
{mergeabilityLabel && checksSummary ? ' • ' : ''}
{checksSummary ?? ''}
{checksTail ? ` (${checksTail})` : ''}
</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
) : (
<RiGitBranchLine
className="h-3.5 w-3.5 flex-shrink-0 translate-y-[0.5px] text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
)
) : null}
<div className="min-w-0 flex flex-col justify-center">
<p className={cn('text-[14px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
{showInlinePrTitle && prIndicator ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-baseline gap-1">
{prIndicator.url ? (
<button
type="button"
className="inline-flex items-baseline leading-none underline hover:no-underline"
onMouseDown={(event) => event.stopPropagation()}
onClick={handlePrLinkClick}
>
#{prIndicator.number}
</button>
) : (
<span className="leading-none">#{prIndicator.number}</span>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
<div className="space-y-1 text-xs">
{(baseBranchLabel || headBranchLabel) ? (
<div className="text-muted-foreground truncate">
{baseBranchLabel && headBranchLabel ? (
<>
<span>{baseBranchLabel}</span>
<RiArrowLeftLongLine className="mx-0.5 inline h-3 w-3 align-[-2px]" />
<span>{headBranchLabel}</span>
</>
) : (
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
)}
</div>
) : null}
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
{(mergeabilityLabel || checksSummary) ? (
<div className="text-muted-foreground truncate">
{mergeabilityLabel ?? ''}
{mergeabilityLabel && checksSummary ? ' • ' : ''}
{checksSummary ?? ''}
{checksTail ? ` (${checksTail})` : ''}
</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
<span>{` ${group.branch}`}</span>
</>
) : (
renderHighlightedText(group.label, normalizedSessionSearchQuery)
)}
</p>
{!showInlinePrTitle && showBranchSubtitle ? (
<span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight">
{prIndicator ? (
<>
{prIndicator.url ? (
<button
type="button"
className="underline hover:no-underline"
onMouseDown={(event) => event.stopPropagation()}
onClick={handlePrLinkClick}
>
#{prIndicator.number}
</button>
) : (
<span>#{prIndicator.number}</span>
)}
{group.branch ? <span>{` ${group.branch}`}</span> : null}
</>
) : (
group.branch
)}
</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>
{group.directory && !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}
{group.directory ? (
<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();
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(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>
) : null}
</div>
{!isCollapsed ? <div className="oc-group-body pb-3">{body}</div> : null}
</div>
);
}