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:
ChangeHow
2026-07-30 17:51:23 +03:00
committed by GitHub
parent 83a66d0a6d
commit 8130381f6c
8 changed files with 221 additions and 162 deletions
@@ -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}
+8 -4
View File
@@ -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%
);
}
-10
View File
@@ -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;
}