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 showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions); const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder); const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder); const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder);
const projectExpandedParentsRef = React.useRef<Set<string>>(new Set()); const projectExpandedParentsRef = React.useRef<Set<string>>(new Set());
const recentExpandedParentsRef = 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 headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
const headerActionIconClass = 'h-4.5 w-4.5'; const headerActionIconClass = 'h-4.5 w-4.5';
const stuckProjectHeaders = useStickyProjectHeaders({ const stuckProjectHeaders = useStickyProjectHeaders({
enabled: isVisible, enabled: isVisible && stickyZoneHeaders,
isDesktopShellRuntime, isDesktopShellRuntime,
projectSections, projectSections,
projectHeaderSentinelRefs, projectHeaderSentinelRefs,
@@ -1861,6 +1862,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
hideDirectoryControls={hideDirectoryControls} hideDirectoryControls={hideDirectoryControls}
projectRepoStatus={projectRepoStatus} projectRepoStatus={projectRepoStatus}
isDesktopShellRuntime={isDesktopShellRuntime} isDesktopShellRuntime={isDesktopShellRuntime}
stickyZoneHeaders={stickyZoneHeaders}
stuckProjectHeaders={stuckProjectHeaders} stuckProjectHeaders={stuckProjectHeaders}
mobileVariant={mobileVariant} mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowSidebarActions} alwaysShowActions={alwaysShowSidebarActions}
@@ -5,7 +5,7 @@
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components. - `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. - 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. - **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. - 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). - 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`). - 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, resolveMenuOpenSessionId,
} from './sessionNodeItemUtils'; } from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils'; import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import { useStickyHeader } from './hooks/useStickyProjectHeaders';
type ActivityItem = { type ActivityItem = {
node: SessionNode; node: SessionNode;
@@ -62,17 +61,12 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
variant = 'section', variant = 'section',
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS, initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
batchSize = MAX_VISIBLE_RECENT_SESSIONS, batchSize = MAX_VISIBLE_RECENT_SESSIONS,
isDesktopShellRuntime,
} = props; } = props;
const { t } = useI18n(); const { t } = useI18n();
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders); const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set()); const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map()); const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
const flatVariant = variant === 'flat'; const flatVariant = variant === 'flat';
const { isStuck: isRecentHeaderStuck, sentinelRef: recentHeaderSentinelRef } = useStickyHeader({
enabled: stickyZoneHeaders && !flatVariant,
isDesktopShellRuntime,
});
const resetSectionLimit = React.useCallback((key: string) => { const resetSectionLimit = React.useCallback((key: string) => {
setVisibleCountBySection((prev) => { setVisibleCountBySection((prev) => {
@@ -184,18 +178,10 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
return ( return (
<div key={section.key} className="relative space-y-1"> <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( <div className={cn(
'-ml-2.5 -mr-2', '-ml-2.5 -mr-2',
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar', stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
stickyZoneHeaders && isRecentHeaderStuck && 'oc-zone-header-backing', )} data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}>
)}>
<button <button
type="button" type="button"
onClick={() => toggleSection(section.key)} onClick={() => toggleSection(section.key)}
@@ -13,12 +13,13 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils'; import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
import type { SessionGroup } from './types'; import type { SessionGroup } from './types';
import type { SortableDragHandleProps } from './sortableItems'; import type { SortableDragHandleProps } from './sortableItems';
import { SortableGroupItem, SortableProjectItem } from './sortableItems'; import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
import { formatProjectLabel } from './utils'; import { formatProjectLabel } from './utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import type { MainTab } from '@/stores/useUIStore'; import type { MainTab } from '@/stores/useUIStore';
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore'; import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { streamPerfCount } from '@/stores/utils/streamDebug'; import { streamPerfCount } from '@/stores/utils/streamDebug';
import { Icon } from '@/components/icon/Icon';
type ProjectSection = { type ProjectSection = {
project: { project: {
@@ -33,6 +34,18 @@ type ProjectSection = {
groups: SessionGroup[]; 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 = { type Props = {
topContent?: React.ReactNode; topContent?: React.ReactNode;
sharedSessionsOnly?: boolean; sharedSessionsOnly?: boolean;
@@ -61,6 +74,7 @@ type Props = {
hideDirectoryControls: boolean; hideDirectoryControls: boolean;
projectRepoStatus: Map<string, boolean | null>; projectRepoStatus: Map<string, boolean | null>;
isDesktopShellRuntime: boolean; isDesktopShellRuntime: boolean;
stickyZoneHeaders: boolean;
stuckProjectHeaders: Set<string>; stuckProjectHeaders: Set<string>;
mobileVariant: boolean; mobileVariant: boolean;
alwaysShowActions: boolean; alwaysShowActions: boolean;
@@ -84,6 +98,8 @@ type Props = {
function SidebarProjectsListComponent(props: Props): React.ReactNode { function SidebarProjectsListComponent(props: Props): React.ReactNode {
streamPerfCount('ui.sidebar_projects_list.render'); streamPerfCount('ui.sidebar_projects_list.render');
const { t } = useI18n(); const { t } = useI18n();
const [hasTopScroll, setHasTopScroll] = React.useState(false);
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders;
const projectSensors = useSensors( const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -120,6 +136,46 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
// can resolve the scrolling ancestor synchronously (no getComputedStyle // can resolve the scrolling ancestor synchronously (no getComputedStyle
// walk) and skip the cost of a style recalc on every render. // walk) and skip the cost of a style recalc on every render.
const scrollContainerRef = React.useRef<HTMLElement | null>(null); 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) { if (props.sharedSessionsOnly) {
return ( return (
@@ -144,7 +200,22 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
// button) and holds it in place, which makes newly revealed sessions look // button) and holds it in place, which makes newly revealed sessions look
// like they insert upward. With anchoring off, scrollTop stays put and new // like they insert upward. With anchoring off, scrollTop stays put and new
// rows appear below naturally. // 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.topContent}
{props.showOnlyMainWorkspace ? ( {props.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1"> <div className="space-y-[0.6rem] py-1">
@@ -198,14 +269,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
{props.sectionsForRender.map((section) => { {props.sectionsForRender.map((section) => {
const project = section.project; const project = section.project;
const projectKey = project.id; const projectKey = project.id;
const projectLabel = formatProjectLabel( const projectLabel = getProjectLabel(project, props.homeDirectory);
project.label?.trim()
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|| project.normalizedPath,
);
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory); const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
const isCollapsed = props.collapsedProjects.has(projectKey); const isCollapsed = props.collapsedProjects.has(projectKey);
const isActiveProject = projectKey === props.activeProjectId;
const isRepo = props.projectRepoStatus.get(projectKey); const isRepo = props.projectRepoStatus.get(projectKey);
return ( return (
@@ -220,10 +286,8 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
projectIconImage={project.iconImage} projectIconImage={project.iconImage}
projectIconBackground={project.iconBackground} projectIconBackground={project.iconBackground}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
isActiveProject={isActiveProject}
isRepo={Boolean(isRepo)} isRepo={Boolean(isRepo)}
isDesktopShell={props.isDesktopShellRuntime} isDesktopShell={props.isDesktopShellRuntime}
isStuck={props.stuckProjectHeaders.has(projectKey)}
hideDirectoryControls={props.hideDirectoryControls} hideDirectoryControls={props.hideDirectoryControls}
mobileVariant={props.mobileVariant} mobileVariant={props.mobileVariant}
alwaysShowActions={props.alwaysShowActions} alwaysShowActions={props.alwaysShowActions}
@@ -307,6 +371,31 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
</DndContext> </DndContext>
)} )}
</ScrollableOverlay> </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>>; 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> => { export const useStickyProjectHeaders = (args: Args): Set<string> => {
const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args; const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set()); const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
React.useEffect(() => { React.useEffect(() => {
if (enabled && isDesktopShellRuntime) { if (!enabled || !isDesktopShellRuntime) {
setStuckProjectHeaders((prev) => (prev.size === 0 ? prev : new Set()));
return; return;
} }
setStuckProjectHeaders((prev) => (prev.size === 0 ? prev : new Set())); const firstSentinel = Array.from(projectHeaderSentinelRefs.current.values()).find((el) => el !== null);
}, [enabled, isDesktopShellRuntime]); const root = firstSentinel?.closest<HTMLElement>('.oc-sidebar-scroller') ?? null;
if (!root) {
React.useEffect(() => {
if (!enabled || !isDesktopShellRuntime) {
return; return;
} }
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { setStuckProjectHeaders((prev) => {
const projectId = (entry.target as HTMLElement).dataset.projectId; const next = new Set(prev);
if (!projectId) { let changed = false;
return; 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);
} }
return changed ? next : prev;
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;
});
}); });
}, },
{ threshold: 0 }, { root, threshold: 0 },
); );
projectHeaderSentinelRefs.current.forEach((el) => { projectHeaderSentinelRefs.current.forEach((el) => {
@@ -9,7 +9,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; 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 { cn } from '@/lib/utils';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -21,20 +21,89 @@ export type SortableDragHandleProps = {
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef']; setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
}; };
export interface SortableProjectItemProps { type ProjectIdentityProps = {
id: string; id: string;
disabled?: boolean;
projectLabel: string; projectLabel: string;
projectDescription: string;
projectIcon?: string; projectIcon?: string;
projectColor?: string; projectColor?: string;
projectIconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' }; projectIconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
projectIconBackground?: string; 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; isCollapsed: boolean;
isActiveProject: boolean;
isRepo: boolean; isRepo: boolean;
isDesktopShell: boolean; isDesktopShell: boolean;
isStuck: boolean;
hideDirectoryControls: boolean; hideDirectoryControls: boolean;
mobileVariant: boolean; mobileVariant: boolean;
alwaysShowActions: boolean; alwaysShowActions: boolean;
@@ -64,10 +133,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
projectIconImage, projectIconImage,
projectIconBackground, projectIconBackground,
isCollapsed, isCollapsed,
isActiveProject,
isRepo, isRepo,
isDesktopShell, isDesktopShell,
isStuck,
hideDirectoryControls, hideDirectoryControls,
alwaysShowActions, alwaysShowActions,
onToggle, onToggle,
@@ -85,7 +152,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
statusIndicator = null, statusIndicator = null,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders); const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
const { const {
attributes, attributes,
@@ -101,9 +167,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
const isMenuOpen = openSidebarMenuKey === menuInstanceKey; const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false); 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) => { const handleMenuOpenChange = React.useCallback((open: boolean) => {
if (open) setIsContextMenuOpen(false); if (open) setIsContextMenuOpen(false);
setOpenSidebarMenuKey(open ? menuInstanceKey : null); setOpenSidebarMenuKey(open ? menuInstanceKey : null);
@@ -187,9 +250,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
render={ render={
// Sticky zone header: this trigger div is a direct child of // Sticky zone header: this trigger div is a direct child of
// the project wrapper (which spans header + sessions), so it // the project wrapper (which spans header + sessions), so it
// can stick for the whole zone. The solid sidebar backing // can stick for the whole zone.
// keeps scrolled session rows from showing through the
// translucent band.
// Full-bleed band: pull past the list container's padding so // Full-bleed band: pull past the list container's padding so
// the section band spans the entire sidebar width (ref: edge- // the section band spans the entire sidebar width (ref: edge-
// to-edge section headers, not rounded pills). // to-edge section headers, not rounded pills).
@@ -197,8 +258,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
className={cn( className={cn(
'-ml-2.5 -mr-2 text-left group/project select-none', '-ml-2.5 -mr-2 text-left group/project select-none',
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar', stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
stickyZoneHeaders && isStuck && 'oc-zone-header-backing',
)} )}
data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}
onContextMenu={(event) => { onContextMenu={(event) => {
// VS Code hides project actions entirely (hideDirectoryControls). // VS Code hides project actions entirely (hideDirectoryControls).
if (hideDirectoryControls) return; if (hideDirectoryControls) return;
@@ -209,14 +270,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
} }
> >
<div <div
className={cn( className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
// 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',
)}
{...attributes} {...attributes}
> >
<Tooltip> <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'), : (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"> <ProjectHeaderIdentity
<span className={cn( id={id}
'h-3.5 w-3.5 items-center justify-center text-muted-foreground', projectLabel={projectLabel}
alwaysShowActions ? 'inline-flex' : 'hidden group-hover/project:inline-flex group-focus-within/project:inline-flex', projectIcon={projectIcon}
)}> projectColor={projectColor}
{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" />} projectIconImage={projectIconImage}
</span> projectIconBackground={projectIconBackground}
{projectIconImage ? ( isCollapsed={isCollapsed}
<span alwaysShowActions={alwaysShowActions}
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>
{statusIndicator ? ( {statusIndicator ? (
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span> <span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
) : null} ) : null}
+8 -4
View File
@@ -316,14 +316,16 @@ div[data-chat-input-footer="true"] {
mask-image: linear-gradient( mask-image: linear-gradient(
to bottom, to bottom,
transparent 0%, 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)), #000 calc(100% - var(--scroll-shadow-size)),
transparent 100% transparent 100%
); );
-webkit-mask-image: linear-gradient( -webkit-mask-image: linear-gradient(
to bottom, to bottom,
transparent 0%, 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)), #000 calc(100% - var(--scroll-shadow-size)),
transparent 100% transparent 100%
); );
@@ -333,13 +335,15 @@ div[data-chat-input-footer="true"] {
mask-image: linear-gradient( mask-image: linear-gradient(
to bottom, to bottom,
transparent 0%, 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% #000 100%
); );
-webkit-mask-image: linear-gradient( -webkit-mask-image: linear-gradient(
to bottom, to bottom,
transparent 0%, 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% #000 100%
); );
} }
-10
View File
@@ -60,7 +60,6 @@
--sidebar-accent-foreground: oklch(0.25 0.02 40); /* Dark warm text */ --sidebar-accent-foreground: oklch(0.25 0.02 40); /* Dark warm text */
--sidebar-border: oklch(0.85 0.02 70); /* Warm border */ --sidebar-border: oklch(0.85 0.02 70); /* Warm border */
--sidebar-ring: oklch(0.65 0.2 55); /* Focus ring */ --sidebar-ring: oklch(0.65 0.2 55); /* Focus ring */
--sidebar-stuck-bg: var(--sidebar); /* Desktop sidebar sticky header background (match sidebar) */
} }
.dark { .dark {
@@ -97,7 +96,6 @@
--sidebar-accent-foreground: oklch(0.85 0.02 90); /* #cdccc3 */ --sidebar-accent-foreground: oklch(0.85 0.02 90); /* #cdccc3 */
--sidebar-border: oklch(0.31 0.01 35); /* #393836 */ --sidebar-border: oklch(0.31 0.01 35); /* #393836 */
--sidebar-ring: oklch(0.77 0.17 85); /* #edb449 */ --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; 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-sans {
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) !important; font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) !important;
} }