feat(ui): sidebar redesign — project zones, grouping modes, full-page surfaces (#2480)

* checkpoint: flatten sidebar core (flat sessions, zones, single mode, folders flat)

* checkpoint: sidebar nav + scheduled/archive full-page surfaces, recent zone header

* checkpoint: worktrees management surface via project menu

* checkpoint: docs, i18n, validation for sidebar redesign

* checkpoint: unified row/zone geometry, recent backfill, tooltips everywhere, primary spinner

* checkpoint: branch icon marker, date moved to rich tooltip, recent back to pure time window

* checkpoint: PR state on branch markers + tooltip, no reserved right space, aligned show-more, instant tooltips

* checkpoint: reserve hover-action space so title text is not overlapped

* checkpoint: tighten hover-action reserve

* checkpoint: color-only unread emphasis to avoid title reflow

* checkpoint: drop new-subfolder action, folder actions overlay on hover

* checkpoint: fix sticky project headers (sticky on trigger div), stuck elevation

* checkpoint: full-bleed semibold zone headers, no top scroll fade

* checkpoint: headers without background tint (typography-only emphasis)

* checkpoint: recent header flush with scroll top (no pre-stick bump)

* checkpoint: shared tooltip provider with grouping (instant handoff between rows)

* checkpoint: blur pointer-click focus so hover chrome hides on mouse-leave

* checkpoint: tooltip closeDelay bridges inter-row gap

* checkpoint: nav above controls, merged view dropdown, project-scoped bulk selection, cross-worktree folders

* checkpoint: folder header tooltip with full path name

* checkpoint: true page surfaces (hidden chat, header title, close-on-select), multirun page, run-now jump, folders on top, controls row polish

* checkpoint: rename mode — dual-instance outside-click fix, no vertical shift

* checkpoint: session grouping mode toggle (by-worktree default, flat option)

* checkpoint: worktree header — hover padding reserve + delete worktree action

* checkpoint: frosted sticky header backing under desktop vibrancy

* checkpoint: align worktree sub-header with project header icon column

* checkpoint: restore worktree group DnD reorder; dense vibrancy header tint (Chromium mask+backdrop-filter)

* checkpoint: vibrancy — drop scroller mask so backdrop-filter samples rows (Chromium backdrop-root limitation)

* checkpoint: vibrancy headers use opaque sidebar tone (Electron transparent-window backdrop-filter bug)

* checkpoint: nav collapsed to one row (New session + surface icons), mirrors Add project row

* checkpoint: raise sticky zone headers above row action layers (z-20)

* checkpoint: New session as full-width CTA + single quiet toolbar row

* checkpoint: New session row back to quiet text form above the toolbar

* checkpoint: align toolbar left icon with New session icon column

* checkpoint: hoverless flat header controls (color-only hover states)

* checkpoint: sticky project headers toggle in view dropdown (default on)

* checkpoint: full-page surfaces adapted — archive directory filter panel, scheduled master-detail, multirun without duplicate title bar

* checkpoint: multirun joins mutually exclusive surface set

* checkpoint: surfaces leave via navigation only (no close/cancel buttons), robust close-on-new-session, drop dead MultiRunWindow

* checkpoint: no scheduled header description, aligned empty-worktree note, collapse/expand covers worktree groups

* checkpoint: overlay scrollbar above sticky zone headers

* checkpoint: archive delete icons reveal on hover with padding shift

* checkpoint: new worktrees surface at top of the worktree list

* checkpoint: no grab cursor on worktree headers

* checkpoint: worktrees page list-only with inline action, no worktrees in edit dialog, drop menu ellipsis

* checkpoint: worktrees page uses full content width

* checkpoint: worktrees header — 'in' instead of em dash, no description

* checkpoint: drop legacy OpenCode badge from worktree list, tooltip without OpenCode mention

* review: bound pr summary cache
This commit is contained in:
Bohdan Triapitsyn
2026-07-28 12:04:39 +03:00
committed by GitHub
parent 5787ea5d49
commit 1291cde5c2
56 changed files with 1902 additions and 1044 deletions
@@ -17,12 +17,18 @@ export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) =>
groupById.delete(id);
}
});
// Groups unknown to the saved order are NEW worktrees — surface them at
// the top of the worktree list (the root/main group is positioned by
// the renderer regardless of this ordering). Archived buckets keep
// appending at the end.
const newGroups: SessionGroup[] = [];
const trailingGroups: SessionGroup[] = [];
groups.forEach((group) => {
if (groupById.has(group.id)) {
ordered.push(group);
}
if (!groupById.has(group.id)) return;
if (group.isArchivedBucket) trailingGroups.push(group);
else newGroups.push(group);
});
return ordered;
return [...newGroups, ...ordered, ...trailingGroups];
},
[groupOrderByProject],
);
@@ -4,6 +4,7 @@ import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import type { MainTab } from '@/stores/useUIStore';
import { useUIStore } from '@/stores/useUIStore';
import { streamPerfMark } from '@/stores/utils/streamDebug';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -65,6 +66,9 @@ export const useSessionActions = (args: Args) => {
const handleSessionSelect = React.useCallback(
(sessionId: string, sessionDirectory?: string | null) => {
streamPerfMark('navigation.session_select');
// Selecting a session always leaves any full-page surface, even when
// the session is already the current one (no store transition fires).
useUIStore.getState().closeMainSurfaces();
const resetSessionSearch = () => {
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
return;
@@ -12,6 +12,7 @@ import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from '
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { getWorktreeFirstSeenAt } from '../worktreeFirstSeen';
type Args = {
homeDirectory: string | null;
@@ -196,7 +197,16 @@ export const useSessionGrouping = (args: Args) => {
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
}
// Third priority: for inactive worktrees, sort by label (asc)
// Third priority: for inactive worktrees, most recently discovered
// first (a worktree created mid-session surfaces at the top of the
// list; startup discovery ties and falls through to labels).
const aSeen = getWorktreeFirstSeenAt(a.path);
const bSeen = getWorktreeFirstSeenAt(b.path);
if (aSeen !== bSeen) {
return bSeen - aSeen;
}
// Fourth priority: sort by label (asc)
const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
return aLabel.localeCompare(bLabel);
@@ -201,6 +201,70 @@ export const useSessionSidebarSections = (args: Args) => {
const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections;
// Flat display sections: one merged group per project containing every
// non-archived session from the project root and all of its worktrees.
// Worktree grouping stays available in `projectSections` for data consumers
// (bootstrap demand planning, ownership); rendering is flat.
// The per-section cache keeps merged group references stable so the
// memoized SessionGroupSection subtree skips unrelated update waves.
const flatSectionCacheRef = React.useRef<WeakMap<ProjectSection, { query: string; section: ProjectSection }>>(new WeakMap());
const flatSectionsForRender = React.useMemo<ProjectSection[]>(() => {
const cache = flatSectionCacheRef.current;
return sectionsForRender.map((section) => {
const cached = cache.get(section);
if (cached && cached.query === normalizedSessionSearchQuery) {
return cached.section;
}
const nonArchivedGroups = section.groups.filter((group) => !group.isArchivedBucket);
const archivedGroups = section.groups.filter((group) => group.isArchivedBucket);
const sessions = nonArchivedGroups.flatMap((group) => hasSessionSearchQuery
? (groupSearchDataByGroup.get(group)?.filteredNodes ?? [])
: group.sessions);
const folderScopes = nonArchivedGroups
.map((group) => ({
scopeKey: group.folderScopeKey ?? normalizePath(group.directory ?? null),
directory: group.directory ?? null,
}))
.filter((scope): scope is { scopeKey: string; directory: string | null } => Boolean(scope.scopeKey));
const rootGroup = nonArchivedGroups.find((group) => group.isMain) ?? null;
const flatGroup: SessionGroup = {
id: 'flat',
label: rootGroup?.label ?? '',
branch: rootGroup?.branch ?? null,
description: rootGroup?.description ?? null,
isMain: true,
isArchivedBucket: false,
worktree: null,
directory: rootGroup?.directory ?? section.project.normalizedPath,
folderScopeKey: rootGroup?.folderScopeKey ?? section.project.normalizedPath,
folderScopes,
sessions,
};
if (hasSessionSearchQuery) {
const merged = nonArchivedGroups
.map((group) => groupSearchDataByGroup.get(group))
.filter((data): data is GroupSearchData => Boolean(data));
groupSearchDataByGroup.set(flatGroup, {
filteredNodes: sessions,
matchedSessionCount: merged.reduce((total, data) => total + data.matchedSessionCount, 0),
folderNameMatchCount: merged.reduce((total, data) => total + data.folderNameMatchCount, 0),
groupMatches: merged.some((data) => data.groupMatches),
hasMatch: merged.some((data) => data.hasMatch),
});
}
const flatSection: ProjectSection = {
project: section.project,
groups: [flatGroup, ...archivedGroups],
};
cache.set(section, { query: normalizedSessionSearchQuery, section: flatSection });
return flatSection;
});
}, [groupSearchDataByGroup, hasSessionSearchQuery, normalizedSessionSearchQuery, sectionsForRender]);
const searchMatchCount = React.useMemo(() => {
if (!hasSessionSearchQuery) {
return 0;
@@ -224,6 +288,7 @@ export const useSessionSidebarSections = (args: Args) => {
groupSearchDataByGroup,
searchableProjectSections,
sectionsForRender,
flatSectionsForRender,
searchMatchCount,
};
};
@@ -8,6 +8,12 @@ type Args = {
isInlineEditing: boolean;
showDeletionDialog: boolean;
foldersMap: Record<string, SessionFolder[]>;
/**
* Selection scope is the project id (flat per-project session list); this
* map resolves it to the project's folder scopes (root + worktrees). When
* the scope is missing here it is treated as a plain directory scope.
*/
folderScopesByProject: Map<string, Array<{ scopeKey: string; directory: string | null }>>;
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
@@ -39,6 +45,7 @@ export const useSidebarBulkActions = (args: Args) => {
isInlineEditing,
showDeletionDialog,
foldersMap,
folderScopesByProject,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename,
@@ -89,38 +96,74 @@ export const useSidebarBulkActions = (args: Args) => {
return null;
}, [hasSelection, selectedIds, selectionScopeKey]);
const bulkScopeFolders = React.useMemo(() => {
// The selection scope is a project id; folders live per directory scope
// (project root + each worktree). Resolve all of them, in project order.
const selectionFolderScopes = React.useMemo<string[]>(() => {
if (!derivedSelectionScope) return [];
return foldersMap[derivedSelectionScope] ?? [];
}, [foldersMap, derivedSelectionScope]);
const projectScopes = folderScopesByProject.get(derivedSelectionScope);
if (projectScopes && projectScopes.length > 0) {
return projectScopes.map((scope) => scope.scopeKey);
}
// Fallback: the scope is already a directory (e.g. VS Code workspaces).
return [derivedSelectionScope];
}, [derivedSelectionScope, folderScopesByProject]);
const bulkScopeFolders = React.useMemo(() => {
return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []);
}, [foldersMap, selectionFolderScopes]);
const resolveFolderScope = React.useCallback((folderId: string): string | null => {
for (const scope of selectionFolderScopes) {
if ((foldersMap[scope] ?? []).some((folder) => folder.id === folderId)) return scope;
}
return null;
}, [foldersMap, selectionFolderScopes]);
const bulkCanRemoveFromFolder = React.useMemo(() => {
if (!derivedSelectionScope || !hasSelection) return false;
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
for (const folder of scopeFolders) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
if (!hasSelection) return false;
for (const scope of selectionFolderScopes) {
for (const folder of foldersMap[scope] ?? []) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
}
}
}
return false;
}, [foldersMap, derivedSelectionScope, hasSelection, selectedIds]);
}, [foldersMap, selectionFolderScopes, hasSelection, selectedIds]);
const moveSelectionToFolder = React.useCallback((targetScope: string, folderId: string) => {
const ids = Array.from(selectedIds);
// Clear memberships in every other scope first — the store only dedupes
// within one scope, and a session must live in a single folder.
for (const scope of selectionFolderScopes) {
if (scope === targetScope) continue;
removeSessionsFromFolders(scope, ids);
}
addSessionsToFolder(targetScope, folderId, ids);
}, [addSessionsToFolder, removeSessionsFromFolders, selectedIds, selectionFolderScopes]);
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
if (!derivedSelectionScope || !hasSelection) return;
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
}, [addSessionsToFolder, selectedIds, derivedSelectionScope, hasSelection]);
if (!hasSelection) return;
const targetScope = resolveFolderScope(folderId);
if (!targetScope) return;
moveSelectionToFolder(targetScope, folderId);
}, [hasSelection, moveSelectionToFolder, resolveFolderScope]);
const handleBulkCreateFolderAndMove = React.useCallback(() => {
if (!derivedSelectionScope || !hasSelection) return;
const newFolder = createFolderAndStartRename(derivedSelectionScope);
const targetScope = selectionFolderScopes[0];
if (!targetScope || !hasSelection) return;
const newFolder = createFolderAndStartRename(targetScope);
if (!newFolder) return;
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope, hasSelection]);
moveSelectionToFolder(targetScope, newFolder.id);
}, [createFolderAndStartRename, hasSelection, moveSelectionToFolder, selectionFolderScopes]);
const handleBulkRemoveFromFolder = React.useCallback(() => {
if (!derivedSelectionScope || !hasSelection) return;
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope, hasSelection]);
if (!hasSelection) return;
const ids = Array.from(selectedIds);
for (const scope of selectionFolderScopes) {
removeSessionsFromFolders(scope, ids);
}
}, [removeSessionsFromFolders, selectedIds, selectionFolderScopes, hasSelection]);
const executeBulkDelete = React.useCallback(async () => {
const ids = Array.from(selectedIds);