* 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
251 lines
12 KiB
TypeScript
251 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|