fix(ui): smooth desktop sidebar sticky transitions (#2528)
* fix(ui): blend vibrant sidebar transitions * fix(ui): fade sidebar content below header * fix(ui): smooth vibrant sidebar sticky headers * fix(ui): scope sticky fade to desktop * fix(ui): respect sticky header preference
This commit is contained in:
@@ -1121,6 +1121,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
||||
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
|
||||
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder);
|
||||
const projectExpandedParentsRef = React.useRef<Set<string>>(new Set());
|
||||
const recentExpandedParentsRef = React.useRef<Set<string>>(new Set());
|
||||
@@ -1516,7 +1517,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
|
||||
const headerActionIconClass = 'h-4.5 w-4.5';
|
||||
const stuckProjectHeaders = useStickyProjectHeaders({
|
||||
enabled: isVisible,
|
||||
enabled: isVisible && stickyZoneHeaders,
|
||||
isDesktopShellRuntime,
|
||||
projectSections,
|
||||
projectHeaderSentinelRefs,
|
||||
@@ -1861,6 +1862,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
hideDirectoryControls={hideDirectoryControls}
|
||||
projectRepoStatus={projectRepoStatus}
|
||||
isDesktopShellRuntime={isDesktopShellRuntime}
|
||||
stickyZoneHeaders={stickyZoneHeaders}
|
||||
stuckProjectHeaders={stuckProjectHeaders}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowSidebarActions}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
|
||||
- Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level.
|
||||
- **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling.
|
||||
- Project headers are sticky background "zone" bands (`SortableProjectItem`); the `recent` section header uses the same band styling. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Unarchive is not possible through the upstream OpenCode HTTP API (`session.update` can only set a finite `time.archived`).
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
resolveMenuOpenSessionId,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import { useStickyHeader } from './hooks/useStickyProjectHeaders';
|
||||
|
||||
type ActivityItem = {
|
||||
node: SessionNode;
|
||||
@@ -62,17 +61,12 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
variant = 'section',
|
||||
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
isDesktopShellRuntime,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||
const flatVariant = variant === 'flat';
|
||||
const { isStuck: isRecentHeaderStuck, sentinelRef: recentHeaderSentinelRef } = useStickyHeader({
|
||||
enabled: stickyZoneHeaders && !flatVariant,
|
||||
isDesktopShellRuntime,
|
||||
});
|
||||
|
||||
const resetSectionLimit = React.useCallback((key: string) => {
|
||||
setVisibleCountBySection((prev) => {
|
||||
@@ -184,18 +178,10 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
|
||||
return (
|
||||
<div key={section.key} className="relative space-y-1">
|
||||
{/* Zone header styled like a project header band; its solid
|
||||
backing applies only after the header is actually stuck. */}
|
||||
<div
|
||||
ref={recentHeaderSentinelRef}
|
||||
className="absolute top-0 h-px w-full pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className={cn(
|
||||
'-ml-2.5 -mr-2',
|
||||
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
|
||||
stickyZoneHeaders && isRecentHeaderStuck && 'oc-zone-header-backing',
|
||||
)}>
|
||||
)} data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
|
||||
@@ -13,12 +13,13 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
|
||||
import type { SessionGroup } from './types';
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import { SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { formatProjectLabel } from './utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
type ProjectSection = {
|
||||
project: {
|
||||
@@ -33,6 +34,18 @@ type ProjectSection = {
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
const TOP_FADE_MAX_SIZE = 48;
|
||||
const TOP_FADE_MIN_SIZE = 32;
|
||||
const TOP_FADE_CLEAR_MAX_SIZE = 24;
|
||||
|
||||
const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => (
|
||||
formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
)
|
||||
);
|
||||
|
||||
type Props = {
|
||||
topContent?: React.ReactNode;
|
||||
sharedSessionsOnly?: boolean;
|
||||
@@ -61,6 +74,7 @@ type Props = {
|
||||
hideDirectoryControls: boolean;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stickyZoneHeaders: boolean;
|
||||
stuckProjectHeaders: Set<string>;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
@@ -84,6 +98,8 @@ type Props = {
|
||||
function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_projects_list.render');
|
||||
const { t } = useI18n();
|
||||
const [hasTopScroll, setHasTopScroll] = React.useState(false);
|
||||
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders;
|
||||
const projectSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -120,6 +136,46 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
||||
// walk) and skip the cost of a style recalc on every render.
|
||||
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
||||
// Keep per-scroll measurements out of React state so the interaction guard
|
||||
// can read the current fade boundary without rerendering the sidebar.
|
||||
const topFadeSizeRef = React.useRef(0);
|
||||
// Update the compositor-owned mask on every scroll, but cross the React
|
||||
// render boundary only when the sticky identity overlay appears or hides.
|
||||
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
|
||||
const hasTopScroll = scroller.scrollTop > 1;
|
||||
const topFadeSize = hasTopScroll
|
||||
? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE)
|
||||
: 0;
|
||||
topFadeSizeRef.current = topFadeSize;
|
||||
scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
|
||||
scroller.style.setProperty(
|
||||
'--scroll-shadow-top-clear-size',
|
||||
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
|
||||
);
|
||||
setHasTopScroll((prev) => (prev === hasTopScroll ? prev : hasTopScroll));
|
||||
}, []);
|
||||
const blockObscuredInteraction = React.useCallback((
|
||||
event: React.MouseEvent<HTMLDivElement> | React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return;
|
||||
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
|
||||
if (eventY >= topFadeSizeRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const hasProjectScroller = props.projectSections.length > 0 && props.sectionsForRender.length > 0;
|
||||
React.useLayoutEffect(() => {
|
||||
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
|
||||
syncTopFade(scrollContainerRef.current);
|
||||
}
|
||||
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
|
||||
let stuckProject: ProjectSection['project'] | null = null;
|
||||
for (const section of props.projectSections) {
|
||||
if (props.stuckProjectHeaders.has(section.project.id)) {
|
||||
stuckProject = section.project;
|
||||
}
|
||||
}
|
||||
const stickyProjectLabel = stuckProject ? getProjectLabel(stuckProject, props.homeDirectory) : null;
|
||||
|
||||
if (props.sharedSessionsOnly) {
|
||||
return (
|
||||
@@ -144,7 +200,22 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
// button) and holds it in place, which makes newly revealed sessions look
|
||||
// like they insert upward. With anchoring off, scrollTop stays put and new
|
||||
// rows appear below naturally.
|
||||
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow hideTopScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}>
|
||||
<div
|
||||
className="relative flex min-h-0 flex-1"
|
||||
onPointerDownCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
ref={scrollContainerRef}
|
||||
useScrollShadow
|
||||
hideTopScrollShadow={!enableStickyFade}
|
||||
scrollShadowSize={96}
|
||||
outerClassName="flex-1 min-h-0"
|
||||
className={cn('oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}
|
||||
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
||||
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
||||
>
|
||||
{props.topContent}
|
||||
{props.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
@@ -198,14 +269,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
{props.sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
const projectLabel = getProjectLabel(project, props.homeDirectory);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||
const isActiveProject = projectKey === props.activeProjectId;
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
|
||||
return (
|
||||
@@ -220,10 +286,8 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
projectIconImage={project.iconImage}
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isActiveProject={isActiveProject}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
@@ -307,6 +371,31 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
</DndContext>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
{enableStickyFade && hasTopScroll && (stuckProject || props.hasSharedSessions) ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-30 flex h-7 items-center gap-1.5 pl-4 pr-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{stuckProject && stickyProjectLabel ? (
|
||||
<ProjectHeaderIdentity
|
||||
id={stuckProject.id}
|
||||
projectLabel={stickyProjectLabel}
|
||||
projectIcon={stuckProject.icon}
|
||||
projectColor={stuckProject.color}
|
||||
projectIconImage={stuckProject.iconImage}
|
||||
projectIconBackground={stuckProject.iconBackground}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
|
||||
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
|
||||
{t('sessions.sidebar.activity.recentTitle')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,78 +7,43 @@ type Args = {
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
};
|
||||
|
||||
type StickyHeaderArgs = {
|
||||
enabled: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
};
|
||||
|
||||
export const useStickyHeader = (args: StickyHeaderArgs) => {
|
||||
const { enabled, isDesktopShellRuntime } = args;
|
||||
const [sentinel, setSentinel] = React.useState<HTMLDivElement | null>(null);
|
||||
const [isStuck, setIsStuck] = React.useState(false);
|
||||
|
||||
const sentinelRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
setSentinel(node);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !isDesktopShellRuntime || !sentinel) {
|
||||
setIsStuck(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
setIsStuck(entry.intersectionRatio < 1);
|
||||
}, { threshold: 1 });
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [enabled, isDesktopShellRuntime, sentinel]);
|
||||
|
||||
return { isStuck, sentinelRef };
|
||||
};
|
||||
|
||||
export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
|
||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enabled && isDesktopShellRuntime) {
|
||||
if (!enabled || !isDesktopShellRuntime) {
|
||||
setStuckProjectHeaders((prev) => (prev.size === 0 ? prev : new Set()));
|
||||
return;
|
||||
}
|
||||
|
||||
setStuckProjectHeaders((prev) => (prev.size === 0 ? prev : new Set()));
|
||||
}, [enabled, isDesktopShellRuntime]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !isDesktopShellRuntime) {
|
||||
const firstSentinel = Array.from(projectHeaderSentinelRefs.current.values()).find((el) => el !== null);
|
||||
const root = firstSentinel?.closest<HTMLElement>('.oc-sidebar-scroller') ?? null;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const projectId = (entry.target as HTMLElement).dataset.projectId;
|
||||
if (!projectId) {
|
||||
return;
|
||||
setStuckProjectHeaders((prev) => {
|
||||
const next = new Set(prev);
|
||||
let changed = false;
|
||||
for (const entry of entries) {
|
||||
const projectId = (entry.target as HTMLElement).dataset.projectId;
|
||||
if (!projectId) continue;
|
||||
|
||||
const rootTop = entry.rootBounds?.top ?? root.getBoundingClientRect().top;
|
||||
const isAboveScroller = !entry.isIntersecting && entry.boundingClientRect.top < rootTop;
|
||||
if (next.has(projectId) === isAboveScroller) continue;
|
||||
|
||||
changed = true;
|
||||
if (isAboveScroller) next.add(projectId);
|
||||
else next.delete(projectId);
|
||||
}
|
||||
|
||||
setStuckProjectHeaders((prev) => {
|
||||
if (!entry.isIntersecting) {
|
||||
if (prev.has(projectId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(projectId);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (!prev.has(projectId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(projectId);
|
||||
return next;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
{ threshold: 0 },
|
||||
{ root, threshold: 0 },
|
||||
);
|
||||
|
||||
projectHeaderSentinelRefs.current.forEach((el) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
@@ -21,20 +21,89 @@ export type SortableDragHandleProps = {
|
||||
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
|
||||
};
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
type ProjectIdentityProps = {
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
projectLabel: string;
|
||||
projectDescription: string;
|
||||
projectIcon?: string;
|
||||
projectColor?: string;
|
||||
projectIconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
projectIconBackground?: string;
|
||||
};
|
||||
|
||||
type ProjectHeaderIdentityProps = ProjectIdentityProps & {
|
||||
isCollapsed?: boolean;
|
||||
alwaysShowActions?: boolean;
|
||||
};
|
||||
|
||||
export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
|
||||
id,
|
||||
projectLabel,
|
||||
projectIcon,
|
||||
projectColor,
|
||||
projectIconImage,
|
||||
projectIconBackground,
|
||||
isCollapsed,
|
||||
alwaysShowActions = false,
|
||||
}) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const projectIconName = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null;
|
||||
const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null;
|
||||
const hasCollapseControl = isCollapsed !== undefined;
|
||||
const iconVisibilityClassName = hasCollapseControl
|
||||
? (alwaysShowActions ? 'hidden' : 'group-hover/project:hidden group-focus-within/project:hidden')
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
{hasCollapseControl ? (
|
||||
<span className={cn(
|
||||
'h-3.5 w-3.5 items-center justify-center text-muted-foreground',
|
||||
alwaysShowActions ? 'inline-flex' : 'hidden group-hover/project:inline-flex group-focus-within/project:inline-flex',
|
||||
)}>
|
||||
<Icon name={isCollapsed ? 'arrow-right-s' : 'arrow-down-s'} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
{projectIconImage ? (
|
||||
<span
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px]',
|
||||
hasCollapseControl && alwaysShowActions ? 'hidden' : 'inline-flex',
|
||||
iconVisibilityClassName,
|
||||
)}
|
||||
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
|
||||
>
|
||||
<ProjectIconImage
|
||||
project={{ id, iconImage: projectIconImage }}
|
||||
options={{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
}}
|
||||
className="h-full w-full object-contain"
|
||||
fallback={projectIconName ? (
|
||||
<Icon name={projectIconName} className="h-3.5 w-3.5" style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<Icon name="folder" className="h-3.5 w-3.5 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
) : projectIconName ? (
|
||||
<Icon name={projectIconName} className={cn('h-3.5 w-3.5', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<Icon name="folder" className={cn('h-3.5 w-3.5 text-muted-foreground/80', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-[14px] font-semibold lowercase text-foreground">{projectLabel}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export interface SortableProjectItemProps extends ProjectIdentityProps {
|
||||
disabled?: boolean;
|
||||
projectDescription: string;
|
||||
isCollapsed: boolean;
|
||||
isActiveProject: boolean;
|
||||
isRepo: boolean;
|
||||
isDesktopShell: boolean;
|
||||
isStuck: boolean;
|
||||
hideDirectoryControls: boolean;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
@@ -64,10 +133,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
projectIconImage,
|
||||
projectIconBackground,
|
||||
isCollapsed,
|
||||
isActiveProject,
|
||||
isRepo,
|
||||
isDesktopShell,
|
||||
isStuck,
|
||||
hideDirectoryControls,
|
||||
alwaysShowActions,
|
||||
onToggle,
|
||||
@@ -85,7 +152,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
statusIndicator = null,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const {
|
||||
attributes,
|
||||
@@ -101,9 +167,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
||||
|
||||
const projectIconName = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null;
|
||||
const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null;
|
||||
|
||||
const handleMenuOpenChange = React.useCallback((open: boolean) => {
|
||||
if (open) setIsContextMenuOpen(false);
|
||||
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
|
||||
@@ -187,9 +250,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
render={
|
||||
// Sticky zone header: this trigger div is a direct child of
|
||||
// the project wrapper (which spans header + sessions), so it
|
||||
// can stick for the whole zone. The solid sidebar backing
|
||||
// keeps scrolled session rows from showing through the
|
||||
// translucent band.
|
||||
// can stick for the whole zone.
|
||||
// Full-bleed band: pull past the list container's padding so
|
||||
// the section band spans the entire sidebar width (ref: edge-
|
||||
// to-edge section headers, not rounded pills).
|
||||
@@ -197,8 +258,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
className={cn(
|
||||
'-ml-2.5 -mr-2 text-left group/project select-none',
|
||||
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
|
||||
stickyZoneHeaders && isStuck && 'oc-zone-header-backing',
|
||||
)}
|
||||
data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}
|
||||
onContextMenu={(event) => {
|
||||
// VS Code hides project actions entirely (hideDirectoryControls).
|
||||
if (hideDirectoryControls) return;
|
||||
@@ -209,14 +270,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
// pl-4 keeps the icon/text aligned with the padded rows below
|
||||
// (container pl-2.5 + band px-1.5 it replaces).
|
||||
'relative flex items-center gap-1 py-1 pl-4 pr-3.5',
|
||||
// Desktop shell reports when the header is actually stuck;
|
||||
// a subtle elevation makes the pinned state readable.
|
||||
isStuck && 'shadow-md',
|
||||
)}
|
||||
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
|
||||
{...attributes}
|
||||
>
|
||||
<Tooltip>
|
||||
@@ -233,47 +287,16 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<span className={cn(
|
||||
'h-3.5 w-3.5 items-center justify-center text-muted-foreground',
|
||||
alwaysShowActions ? 'inline-flex' : 'hidden group-hover/project:inline-flex group-focus-within/project:inline-flex',
|
||||
)}>
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
{projectIconImage ? (
|
||||
<span
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px]',
|
||||
alwaysShowActions ? 'hidden' : 'inline-flex group-hover/project:hidden group-focus-within/project:hidden',
|
||||
)}
|
||||
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
|
||||
>
|
||||
<ProjectIconImage
|
||||
project={{ id, iconImage: projectIconImage }}
|
||||
options={{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
}}
|
||||
className="h-full w-full object-contain"
|
||||
fallback={projectIconName ? (
|
||||
<Icon name={projectIconName} className="h-3.5 w-3.5" style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<Icon name="folder" className="h-3.5 w-3.5 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
) : projectIconName ? (
|
||||
<Icon name={projectIconName} className={cn('h-3.5 w-3.5', alwaysShowActions ? 'hidden' : 'group-hover/project:hidden group-focus-within/project:hidden')} style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<Icon name="folder" className={cn('h-3.5 w-3.5 text-muted-foreground/80', alwaysShowActions ? 'hidden' : 'group-hover/project:hidden group-focus-within/project:hidden')} style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-[14px] font-semibold truncate lowercase',
|
||||
isActiveProject ? 'text-foreground' : 'text-foreground group-hover/project:text-foreground',
|
||||
)}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
<ProjectHeaderIdentity
|
||||
id={id}
|
||||
projectLabel={projectLabel}
|
||||
projectIcon={projectIcon}
|
||||
projectColor={projectColor}
|
||||
projectIconImage={projectIconImage}
|
||||
projectIconBackground={projectIconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
/>
|
||||
{statusIndicator ? (
|
||||
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
|
||||
) : null}
|
||||
|
||||
@@ -316,14 +316,16 @@ div[data-chat-input-footer="true"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 var(--scroll-shadow-size),
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 var(--scroll-shadow-size),
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 calc(100% - var(--scroll-shadow-size)),
|
||||
transparent 100%
|
||||
);
|
||||
@@ -333,13 +335,15 @@ div[data-chat-input-footer="true"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 var(--scroll-shadow-size),
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
#000 var(--scroll-shadow-size),
|
||||
transparent var(--scroll-shadow-top-clear-size, 0px),
|
||||
#000 var(--scroll-shadow-top-size, var(--scroll-shadow-size)),
|
||||
#000 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
--sidebar-accent-foreground: oklch(0.25 0.02 40); /* Dark warm text */
|
||||
--sidebar-border: oklch(0.85 0.02 70); /* Warm border */
|
||||
--sidebar-ring: oklch(0.65 0.2 55); /* Focus ring */
|
||||
--sidebar-stuck-bg: var(--sidebar); /* Desktop sidebar sticky header background (match sidebar) */
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -97,7 +96,6 @@
|
||||
--sidebar-accent-foreground: oklch(0.85 0.02 90); /* #cdccc3 */
|
||||
--sidebar-border: oklch(0.31 0.01 35); /* #393836 */
|
||||
--sidebar-ring: oklch(0.77 0.17 85); /* #edb449 */
|
||||
--sidebar-stuck-bg: var(--sidebar); /* Desktop sidebar sticky header background (match sidebar) */
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -142,14 +140,6 @@
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Stuck sidebar zone headers must fully mask the rows scrolling under
|
||||
them. Electron's transparent/vibrancy windows break backdrop-filter
|
||||
(electron#20357), so use the opaque sidebar tone instead. */
|
||||
:root.desktop-runtime[data-oc-vibrancy] .oc-zone-header-backing {
|
||||
background: var(--sidebar-stuck-bg) !important;
|
||||
background-color: var(--sidebar-stuck-bg) !important;
|
||||
}
|
||||
|
||||
.font-sans {
|
||||
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user