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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user