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
@@ -1300,6 +1300,37 @@ export const Header: React.FC<HeaderProps> = ({
return trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : 'Untitled Session';
}, [activeProjectLabel, currentSession?.title, currentSessionId]);
// Full-page surfaces (Scheduled, Archive, Worktrees, Multi-run) replace the
// chat area; while one is open the header shows the surface identity
// instead of the session switcher.
const isScheduledSurfaceOpen = useUIStore((state) => state.isScheduledTasksDialogOpen);
const isArchiveSurfaceOpen = useUIStore((state) => state.isArchivePageOpen);
const worktreesSurfaceProjectId = useUIStore((state) => state.worktreesPageProjectId);
const isMultiRunSurfaceOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
const worktreesSurfaceProjectLabel = useProjectsStore((state) => {
if (!worktreesSurfaceProjectId) return null;
const project = state.projects.find((entry) => entry.id === worktreesSurfaceProjectId);
return project?.label?.trim() || project?.path?.split('/').pop() || null;
});
const activeSurfaceHeader = React.useMemo<{ title: string; subtitle: string | null } | null>(() => {
if (isScheduledSurfaceOpen) {
return { title: t('sessions.scheduledTasks.dialog.title'), subtitle: null };
}
if (isArchiveSurfaceOpen) {
return { title: t('sessions.archivePage.title'), subtitle: null };
}
if (worktreesSurfaceProjectId) {
return {
title: t('sessions.worktreesPage.title', { project: worktreesSurfaceProjectLabel ?? '' }),
subtitle: null,
};
}
if (isMultiRunSurfaceOpen) {
return { title: t('sessions.sidebar.header.actions.newMultiRun'), subtitle: null };
}
return null;
}, [isArchiveSurfaceOpen, isMultiRunSurfaceOpen, isScheduledSurfaceOpen, t, worktreesSurfaceProjectId, worktreesSurfaceProjectLabel]);
const actionDirectory = React.useMemo(() => {
return normalize(openDirectory || activeProject?.path || '');
@@ -2039,6 +2070,18 @@ export const Header: React.FC<HeaderProps> = ({
TitlebarLeftControls overlay; the header reserves matching left space
via padding (see headerStyle) when the sidebar is collapsed. */}
<div className="flex min-w-0 flex-1 items-center">
{activeSurfaceHeader ? (
<div className="mr-3 flex min-w-0 flex-col items-start px-1 py-0.5 -my-0.5 text-left">
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{activeSurfaceHeader.title}
</span>
{activeSurfaceHeader.subtitle ? (
<span className="truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75 max-w-full">
{activeSurfaceHeader.subtitle}
</span>
) : null}
</div>
) : (
<SessionSwitcherDropdown>
<button
type="button"
@@ -2070,6 +2113,7 @@ export const Header: React.FC<HeaderProps> = ({
) : null}
</button>
</SessionSwitcherDropdown>
)}
{tabs.length > 0 && (
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
@@ -13,6 +13,9 @@ import { HelpDialog } from '../ui/HelpDialog';
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs';
import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog';
import { ArchiveView } from '@/components/views/ArchiveView';
import { WorktreesView } from '@/components/views/WorktreesView';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { MultiRunLauncher } from '@/components/multirun';
import { TerminalView } from '@/components/views/TerminalView';
@@ -37,7 +40,6 @@ import { PlanView } from '@/components/views/PlanView';
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
export const MainLayout: React.FC = () => {
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
@@ -49,6 +51,32 @@ export const MainLayout: React.FC = () => {
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
const isScheduledTasksPageOpen = useUIStore((state) => state.isScheduledTasksDialogOpen);
const isArchivePageOpen = useUIStore((state) => state.isArchivePageOpen);
const worktreesPageProjectId = useUIStore((state) => state.worktreesPageProjectId);
// Any full-page surface replacing the chat area. While open, the chat and
// secondary views are fully hidden (not just covered) so none of their
// floating chrome bleeds through, and selecting a session / draft / main
// tab anywhere closes the surface.
const isSurfacePageOpen = isScheduledTasksPageOpen || isArchivePageOpen || Boolean(worktreesPageProjectId) || isMultiRunLauncherOpen;
React.useEffect(() => {
const closeSurfacePages = () => useUIStore.getState().closeMainSurfaces();
const unsubscribeSession = useSessionUIStore.subscribe((state, prev) => {
const sessionSelected = Boolean(state.currentSessionId) && state.currentSessionId !== prev.currentSessionId;
// Draft identity change covers re-opening a draft while one is
// already open (the boolean alone never transitions then).
const draftOpened = Boolean(state.newSessionDraft?.open) && state.newSessionDraft !== prev.newSessionDraft;
if (sessionSelected || draftOpened) closeSurfacePages();
});
const unsubscribeTab = useUIStore.subscribe((state, prev) => {
if (state.activeMainTab !== prev.activeMainTab) closeSurfacePages();
});
return () => {
unsubscribeSession();
unsubscribeTab();
};
}, []);
const { isMobile } = useDeviceInfo();
const mobilePanelsResetRef = React.useRef(false);
@@ -306,11 +334,11 @@ export const MainLayout: React.FC = () => {
)}
>
<main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
</div>
{secondaryView && (
<div className="absolute inset-0">
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
@@ -325,6 +353,9 @@ export const MainLayout: React.FC = () => {
</ErrorBoundary>
</div>
)}
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary><ArchiveView /></ErrorBoundary>
<ErrorBoundary><WorktreesView /></ErrorBoundary>
{/* Always mount SessionSidebar on mobile to match desktop behavior.
Conditional mount (mobileLeftDrawerVisible && ...) caused a
data-loading cascade on every drawer open: paginated sessions
@@ -395,14 +426,31 @@ export const MainLayout: React.FC = () => {
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
</div>
{secondaryView && (
<div className="absolute inset-0">
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background">
<ErrorBoundary>
{/* isWindowed: the app Header already shows the surface
title, so skip the launcher's own title bar. */}
<MultiRunLauncher
isWindowed
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary><ArchiveView /></ErrorBoundary>
<ErrorBoundary><WorktreesView /></ErrorBoundary>
</main>
<ContextPanel />
</div>
@@ -422,13 +470,6 @@ export const MainLayout: React.FC = () => {
onOpenChange={setSettingsDialogOpen}
/>
</React.Suspense>
<React.Suspense fallback={null}>
<MultiRunWindow
open={isMultiRunLauncherOpen}
onOpenChange={setMultiRunLauncherOpen}
initialPrompt={multiRunLauncherPrefillPrompt}
/>
</React.Suspense>
</>
)}
@@ -24,7 +24,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
<ScrollableOverlay outerClassName="max-h-[min(90vh,48rem)]" className="w-full bg-background">
<div className="w-full p-3 sm:p-6 sm:pt-8">
{open && project ? (
<ProjectSettingsPanel project={project} onIdentitySave={onSave} />
<ProjectSettingsPanel project={project} onIdentitySave={onSave} showWorktrees={false} />
) : null}
</div>
</ScrollableOverlay>