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
@@ -0,0 +1,250 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { Icon } from '@/components/icon/Icon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatDirectoryName } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { formatSessionDateLabel, normalizePath } from '@/components/session/sidebar/utils';
import { useShallow } from 'zustand/react/shallow';
type DirectoryBucket = {
directory: string;
label: string;
sessions: Session[];
};
// Bound the mounted DOM: archives grow into the hundreds; batch rendering
// keeps the list responsive without a virtualizer.
const PAGE_SIZE = 100;
export function ArchiveView(): React.ReactNode {
const { t } = useI18n();
const open = useUIStore((state) => state.isArchivePageOpen);
const setOpen = useUIStore((state) => state.setArchivePageOpen);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : []));
const [query, setQuery] = React.useState('');
const [selectedDirectory, setSelectedDirectory] = React.useState<string | null>(null);
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE);
const normalizedQuery = query.trim().toLowerCase();
const sortedSessions = React.useMemo(() => {
if (!open) return [];
return [...archivedSessions].sort((a, b) => (b.time?.archived ?? 0) - (a.time?.archived ?? 0));
}, [archivedSessions, open]);
const buckets = React.useMemo<DirectoryBucket[]>(() => {
const byDirectory = new Map<string, DirectoryBucket>();
for (const session of sortedSessions) {
const directory = normalizePath(resolveGlobalSessionDirectory(session)) ?? '';
const existing = byDirectory.get(directory);
if (existing) {
existing.sessions.push(session);
continue;
}
byDirectory.set(directory, {
directory,
label: directory
? (formatDirectoryName(directory, homeDirectory) || directory)
: t('sessions.archivePage.otherProjects'),
sessions: [session],
});
}
return [...byDirectory.values()].sort((a, b) => b.sessions.length - a.sessions.length);
}, [homeDirectory, sortedSessions, t]);
// Search spans every archived session; the directory filter applies only
// while not searching.
const filteredSessions = React.useMemo(() => {
if (normalizedQuery) {
return sortedSessions.filter((session) => (session.title ?? '').toLowerCase().includes(normalizedQuery));
}
if (selectedDirectory === null) return sortedSessions;
return buckets.find((bucket) => bucket.directory === selectedDirectory)?.sessions ?? [];
}, [buckets, normalizedQuery, selectedDirectory, sortedSessions]);
const visibleSessions = filteredSessions.slice(0, visibleCount);
const remainingCount = filteredSessions.length - visibleSessions.length;
const totalCount = archivedSessions.length;
const selectDirectory = React.useCallback((directory: string | null) => {
setSelectedDirectory(directory);
setVisibleCount(PAGE_SIZE);
}, []);
const openSession = React.useCallback((session: Session) => {
const directory = normalizePath(resolveGlobalSessionDirectory(session));
setCurrentSession(session.id, directory ?? undefined);
setActiveMainTab('chat');
setOpen(false);
}, [setActiveMainTab, setCurrentSession, setOpen]);
if (!open) return null;
const renderDirectoryItem = (
key: string,
label: string,
count: number,
isSelected: boolean,
onSelect: () => void,
fullPath?: string,
sessionsForDelete?: Session[],
) => (
<div key={key} className="group/dir relative">
<button
type="button"
onClick={onSelect}
title={fullPath}
className={cn(
'flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left typography-ui-label transition-[padding] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
sessionsForDelete ? 'group-hover/dir:pr-8 group-focus-within/dir:pr-8' : '',
isSelected
? 'bg-interactive-selection text-foreground'
: 'text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground',
)}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
<span className="flex-shrink-0 typography-micro text-muted-foreground/70">{count}</span>
</button>
{sessionsForDelete ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => sessionEvents.requestDelete({ sessions: sessionsForDelete, mode: 'session' })}
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover/dir:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('sessions.archivePage.deleteProjectAria', { label })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>{t('sessions.archivePage.deleteProject')}</TooltipContent>
</Tooltip>
) : null}
</div>
);
return (
<div className="absolute inset-0 z-10 flex flex-col bg-background">
<div className="flex min-h-0 flex-1">
{/* Directory filter panel */}
<div className="flex w-64 flex-shrink-0 flex-col border-r border-border/50">
<div className="flex-1 space-y-0.5 overflow-y-auto p-2">
{renderDirectoryItem(
'__all__',
t('sessions.archivePage.allDirectories'),
totalCount,
selectedDirectory === null,
() => selectDirectory(null),
)}
{buckets.map((bucket) => renderDirectoryItem(
bucket.directory || '__none__',
bucket.label,
bucket.sessions.length,
selectedDirectory === bucket.directory,
() => selectDirectory(bucket.directory),
bucket.directory || undefined,
bucket.sessions,
))}
</div>
</div>
{/* Session list */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-3 px-6 pt-3">
<div className="relative min-w-0 flex-1">
<Icon name="search" className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(event) => {
setQuery(event.target.value);
setVisibleCount(PAGE_SIZE);
}}
placeholder={t('sessions.archivePage.searchPlaceholder')}
className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-3 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
/>
</div>
{/* Pages have no close button: you leave via the sidebar. */}
<span className="flex-shrink-0 typography-micro text-muted-foreground">
{filteredSessions.length === 1
? t('sessions.archivePage.countSingle', { count: filteredSessions.length })
: t('sessions.archivePage.countPlural', { count: filteredSessions.length })}
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
<div className="mx-auto w-full max-w-3xl space-y-0.5">
{visibleSessions.length === 0 ? (
<div className="py-10 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">
{normalizedQuery ? t('sessions.archivePage.empty.noMatches') : t('sessions.archivePage.empty.noArchived')}
</p>
</div>
) : visibleSessions.map((session) => {
const sessionDirectory = normalizePath(resolveGlobalSessionDirectory(session)) ?? '';
const directoryLabel = sessionDirectory
? (formatDirectoryName(sessionDirectory, homeDirectory) || sessionDirectory)
: null;
return (
<div
key={session.id}
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-8 focus-within:pr-8"
onClick={() => openSession(session)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openSession(session);
}
}}
>
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
{session.title || t('sessions.sidebar.session.untitled')}
</span>
{normalizedQuery && directoryLabel ? (
<span className="max-w-40 flex-shrink-0 truncate text-[0.72rem] text-muted-foreground/70" title={sessionDirectory}>
{directoryLabel}
</span>
) : null}
<span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75">
{formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())}
</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
sessionEvents.requestDelete({ sessions: [session], mode: 'session' });
}}
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity pointer-events-none hover:text-destructive group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('sessions.archivePage.deleteSessionAria', { title: session.title || t('sessions.sidebar.session.untitled') })}
>
<Icon name="delete-bin" className="h-3.5 w-3.5" />
</button>
</div>
);
})}
{remainingCount > 0 ? (
<button
type="button"
onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}
className="mt-1 flex items-center justify-start rounded-md px-2 py-1 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{t('sessions.sidebar.group.showMore')}
</button>
) : null}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,85 +0,0 @@
import React from 'react';
import { Dialog } from '@base-ui/react/dialog';
import { cn } from '@/lib/utils';
import { MultiRunLauncher } from '@/components/multirun';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
interface MultiRunWindowProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialPrompt?: string;
}
export const MultiRunWindow: React.FC<MultiRunWindowProps> = ({
open,
onOpenChange,
initialPrompt,
}) => {
const descriptionId = React.useId();
const { t } = useI18n();
const hasOpenFloatingMenu = React.useCallback(() => {
if (typeof document === 'undefined') {
return false;
}
return Boolean(
document.querySelector('[data-slot="dropdown-menu-content"][data-open], [data-slot="select-content"][data-open]')
);
}, []);
return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && hasOpenFloatingMenu()) return;
onOpenChange(next);
}}
>
<Dialog.Portal>
<Dialog.Backdrop
className={cn(
'fixed inset-0 z-50 bg-black/50 dark:bg-black/75',
'transition-opacity duration-150 ease-out',
'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',
)}
/>
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
<Dialog.Popup
aria-describedby={descriptionId}
className={cn(
'relative pointer-events-auto',
'w-[90vw] max-w-[720px] h-[680px] max-h-[85vh]',
'flex flex-col rounded-xl border shadow-none overflow-hidden origin-center',
'bg-background',
'transition-all duration-150 ease-out',
'data-[starting-style]:opacity-0 data-[starting-style]:scale-[0.98]',
'data-[ending-style]:opacity-0 data-[ending-style]:scale-[0.98]',
)}
>
<div className="absolute right-0.5 top-0.5 z-50">
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label={t('multiRun.window.actions.closeAria')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="close" className="h-5 w-5" />
</button>
</div>
<Dialog.Description id={descriptionId} className="sr-only">
{t('multiRun.window.description')}
</Dialog.Description>
<MultiRunLauncher
initialPrompt={initialPrompt}
onCreated={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
isWindowed
/>
</Dialog.Popup>
</div>
</Dialog.Portal>
</Dialog.Root>
);
};
@@ -0,0 +1,42 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
// Full-page worktree management surface for a single project, opened from the
// project menu in the sidebar. Renders only the worktree list (setup commands
// stay in project settings); the New-worktree action leads the content flow.
export function WorktreesView(): React.ReactNode {
const { t } = useI18n();
const projectId = useUIStore((state) => state.worktreesPageProjectId);
const setNewWorktreeDialogOpen = useUIStore((state) => state.setNewWorktreeDialogOpen);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const project = useProjectsStore((state) => state.projects.find((entry) => entry.id === projectId) ?? null);
if (!projectId || !project) return null;
return (
<div className="absolute inset-0 z-10 flex flex-col bg-background">
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="mx-auto w-full max-w-4xl space-y-4">
<div className="flex items-center">
<Button
size="sm"
onClick={() => {
setActiveProjectIdOnly(project.id);
setNewWorktreeDialogOpen(true);
}}
>
<Icon name="node-tree" className="mr-1 h-3.5 w-3.5" />
{t('sessions.sidebar.project.actions.newWorktree')}
</Button>
</div>
<WorktreeSectionContent projectRef={{ id: project.id, path: project.path }} sections="list-only" />
</div>
</div>
</div>
);
}