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:
committed by
GitHub
parent
5787ea5d49
commit
1291cde5c2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user