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
@@ -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);