fix(ui): preserve upstream behavior after performance rebase
This commit is contained in:
+3
-22
@@ -7,9 +7,6 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { SessionProjectScroller } from './SessionProjectScroller';
|
||||
import { RecentSessionSection } from '../recent/RecentSessionSection';
|
||||
import { SidebarActivitySections } from '../recent/SidebarActivitySections';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
type FolderCallbacks = {
|
||||
@@ -27,25 +24,6 @@ type RowPropsCapture = Pick<SessionGroupSectionProps,
|
||||
| 'setCopiedSessionId'
|
||||
>;
|
||||
|
||||
type ExpectNever<T extends never> = T;
|
||||
type RowDomainCallback =
|
||||
| 'handleSaveEdit'
|
||||
| 'handleCancelEdit'
|
||||
| 'handleSessionSelect'
|
||||
| 'handleSessionDoubleClick'
|
||||
| 'handleShareSession'
|
||||
| 'handleCopyShareUrl'
|
||||
| 'handleCopySessionId'
|
||||
| 'handleUnshareSession'
|
||||
| 'handleDeleteSession'
|
||||
| 'handleRestoreSession';
|
||||
|
||||
// Structural group contracts must not expose the row domain action surface.
|
||||
type _SessionGroupSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof SessionGroupSectionProps, RowDomainCallback>>;
|
||||
type _SessionProjectScrollerHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SessionProjectScroller>, RowDomainCallback>>;
|
||||
type _RecentSessionSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof RecentSessionSection>, RowDomainCallback>>;
|
||||
type _SidebarActivitySectionsHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SidebarActivitySections>, RowDomainCallback>>;
|
||||
|
||||
let folderCallbacks: FolderCallbacks | null = null;
|
||||
let rowPropsCapture: RowPropsCapture | null = null;
|
||||
|
||||
@@ -80,6 +58,9 @@ mock.module('@/sync/sync-context', () => ({
|
||||
|
||||
mock.module('../sessions/collapsedActivityIndicator', () => ({
|
||||
CollapsedSessionActivityIndicator: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../sessions/collapsedActivityState', () => ({
|
||||
useCollapsedSessionActivityState: () => null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
selectFolderIdsForProjection,
|
||||
selectFolderRootNodes,
|
||||
} from '../sessions/sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
|
||||
type FolderScope = { scopeKey: string; directory: string | null };
|
||||
@@ -40,7 +39,8 @@ import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrSt
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
|
||||
import { CollapsedSessionActivityIndicator, useCollapsedSessionActivityState } from '../sessions/collapsedActivityIndicator';
|
||||
import { CollapsedSessionActivityIndicator } from '../sessions/collapsedActivityIndicator';
|
||||
import { useCollapsedSessionActivityState } from '../sessions/collapsedActivityState';
|
||||
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
import { FolderDeleteConfirmDialog } from '../shell/ConfirmDialogs';
|
||||
|
||||
@@ -445,18 +445,6 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
|
||||
return allFoldersForGroupBase.filter(({ folder }) => visibleFolderIds.has(folder.id));
|
||||
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
|
||||
|
||||
const groupSessionIds = React.useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
const visit = (nodes: SessionNode[]) => nodes.forEach((node) => {
|
||||
ids.add(node.session.id);
|
||||
visit(node.children);
|
||||
});
|
||||
visit(sourceGroupNodes);
|
||||
return ids;
|
||||
}, [sourceGroupNodes]);
|
||||
const groupExpansionKeys = React.useMemo(() => new Set(
|
||||
[...groupSessionIds].map((id) => `project:${group.isArchivedBucket ? 'archived' : 'active'}:${id}`),
|
||||
), [group.isArchivedBucket, groupSessionIds]);
|
||||
const effectiveEditingId = editingId;
|
||||
const effectiveOpenMenuKey = openSidebarMenuKey;
|
||||
const effectiveExpandedParents = expandedParents;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildGroupRenderDescriptors } from './sessionProjectRender';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections } from './sessionProjectRender';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
const makeGroup = (id: string, overrides: Partial<SessionGroup> = {}): SessionGroup => ({
|
||||
id,
|
||||
@@ -68,3 +69,23 @@ describe('buildGroupRenderDescriptors', () => {
|
||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-project scroller projection', () => {
|
||||
test('renders only the selected project from persisted display state', () => {
|
||||
const previous = useSessionDisplayStore.getState();
|
||||
const sections = [
|
||||
{ project: { id: 'project-a', normalizedPath: '/workspace/a' }, groups: [] },
|
||||
{ project: { id: 'project-b', normalizedPath: '/workspace/b' }, groups: [] },
|
||||
];
|
||||
|
||||
try {
|
||||
useSessionDisplayStore.setState({ projectDisplayMode: 'single', singleProjectId: 'project-b' });
|
||||
const state = useSessionDisplayStore.getState();
|
||||
|
||||
expect(selectRenderedProjectSections(sections, state.projectDisplayMode === 'single', state.singleProjectId)
|
||||
.map((section) => section.project.id)).toEqual(['project-b']);
|
||||
} finally {
|
||||
useSessionDisplayStore.setState(previous, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { buildGroupRenderDescriptors, type ProjectSection } from './sessionProjectRender';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender';
|
||||
import { formatProjectLabel } from '../utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||
@@ -78,6 +78,8 @@ type SessionProjectScrollerModel = {
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
singleProjectMode: boolean;
|
||||
singleProjectId: string | null;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
@@ -114,6 +116,7 @@ type SessionProjectScrollerActions = {
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
setSingleProjectId: (id: string) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -139,7 +142,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const { model, view, actions } = props;
|
||||
const isInlineEditing = model.state.editingId !== null;
|
||||
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders;
|
||||
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders && !model.singleProjectMode;
|
||||
const projectSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -180,7 +183,12 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
const hasProjectScroller = model.projectSections.length > 0 && model.sectionsForRender.length > 0;
|
||||
const renderedSections = selectRenderedProjectSections(
|
||||
model.sectionsForRender,
|
||||
model.singleProjectMode,
|
||||
model.singleProjectId,
|
||||
);
|
||||
const hasProjectScroller = model.projectSections.length > 0 && renderedSections.length > 0;
|
||||
React.useLayoutEffect(() => {
|
||||
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
|
||||
syncTopFade(scrollContainerRef.current);
|
||||
@@ -199,8 +207,17 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
// ready in the same frame; the observer then corrects it. When shared sessions
|
||||
// lead the list, the Recent fallback below owns the top instead of a project.
|
||||
const leadingProject =
|
||||
stuckProject ?? (model.hasSharedSessions ? null : model.sectionsForRender[0]?.project ?? null);
|
||||
stuckProject ?? (model.hasSharedSessions ? null : renderedSections[0]?.project ?? null);
|
||||
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
|
||||
const projectPickerOptions = React.useMemo(() => model.projectSections.map((section) => ({
|
||||
id: section.project.id,
|
||||
projectLabel: getProjectLabel(section.project, view.homeDirectory),
|
||||
projectDescription: formatPathForDisplay(section.project.normalizedPath, view.homeDirectory),
|
||||
projectIcon: section.project.icon,
|
||||
projectColor: section.project.color,
|
||||
projectIconImage: section.project.iconImage,
|
||||
projectIconBackground: section.project.iconBackground,
|
||||
})), [model.projectSections, view.homeDirectory]);
|
||||
|
||||
if (model.projectSections.length === 0) {
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.topContent}{model.emptyState}</ScrollableOverlay>;
|
||||
@@ -237,7 +254,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
{view.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
{(() => {
|
||||
const activeSection = model.sectionsForRender.find((section) => section.project.id === model.activeProjectId) ?? model.sectionsForRender[0];
|
||||
const activeSection = renderedSections.find((section) => section.project.id === model.activeProjectId) ?? renderedSections[0];
|
||||
if (!activeSection) {
|
||||
return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
|
||||
}
|
||||
@@ -270,20 +287,20 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
actions.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={model.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{model.sectionsForRender.map((section) => {
|
||||
<SortableContext items={renderedSections.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{renderedSections.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = getProjectLabel(project, view.homeDirectory);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory);
|
||||
const isCollapsed = view.collapsedProjects.has(projectKey);
|
||||
const isCollapsed = model.singleProjectMode ? false : view.collapsedProjects.has(projectKey);
|
||||
const isRepo = model.projectRepoStatus.get(projectKey);
|
||||
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
disabled={view.projectSortOrder !== 'manual'}
|
||||
disabled={model.singleProjectMode || view.projectSortOrder !== 'manual'}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
@@ -298,8 +315,10 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
alwaysShowActions={view.alwaysShowActions}
|
||||
statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||
openSidebarMenuKey={model.state.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
||||
onToggle={() => actions.toggleProject(projectKey)}
|
||||
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
||||
projectPickerOptions={model.singleProjectMode ? projectPickerOptions : undefined}
|
||||
onProjectSelect={model.singleProjectMode ? actions.setSingleProjectId : undefined}
|
||||
onToggle={() => { if (!model.singleProjectMode) actions.toggleProject(projectKey); }}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||
if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
|
||||
|
||||
@@ -13,6 +13,14 @@ export type ProjectSection = {
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
export const selectRenderedProjectSections = (
|
||||
sections: ProjectSection[],
|
||||
singleProjectMode: boolean,
|
||||
singleProjectId: string | null,
|
||||
): ProjectSection[] => singleProjectMode
|
||||
? sections.filter((section) => section.project.id === singleProjectId)
|
||||
: sections;
|
||||
|
||||
type GroupRenderDescriptor = {
|
||||
group: SessionGroup;
|
||||
groupKey: string;
|
||||
|
||||
@@ -35,6 +35,8 @@ type ProjectHeaderIdentityProps = ProjectIdentityProps & {
|
||||
alwaysShowActions?: boolean;
|
||||
};
|
||||
|
||||
type ProjectPickerOption = ProjectIdentityProps & { projectDescription: string };
|
||||
|
||||
export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
|
||||
id,
|
||||
projectLabel,
|
||||
@@ -121,6 +123,8 @@ export interface SortableProjectItemProps extends ProjectIdentityProps {
|
||||
statusIndicator?: React.ReactNode;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
projectPickerOptions?: ProjectPickerOption[];
|
||||
onProjectSelect?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
@@ -150,6 +154,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
statusIndicator = null,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
projectPickerOptions,
|
||||
onProjectSelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
@@ -166,6 +172,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
const menuInstanceKey = `project:${id}`;
|
||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
||||
const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
|
||||
|
||||
const handleMenuOpenChange = React.useCallback((open: boolean) => {
|
||||
if (open) setIsContextMenuOpen(false);
|
||||
@@ -273,7 +280,28 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
|
||||
{...attributes}
|
||||
>
|
||||
<Tooltip>
|
||||
{isProjectPicker ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
|
||||
>
|
||||
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} />
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[70vh] min-w-[220px] overflow-y-auto">
|
||||
{projectPickerOptions?.map((option) => (
|
||||
<DropdownMenuItem key={option.id} onClick={() => onProjectSelect?.(option.id)} className="flex items-center justify-between gap-3" title={option.projectDescription}>
|
||||
<ProjectHeaderIdentity {...option} />
|
||||
{option.id === id ? <Icon name="check" className="h-4 w-4 flex-shrink-0 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -305,7 +333,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{projectDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Tooltip>}
|
||||
|
||||
<div className={cn(
|
||||
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
|
||||
|
||||
@@ -274,7 +274,7 @@ export const useSessionGrouping = (args: Args) => {
|
||||
|
||||
return groups;
|
||||
},
|
||||
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||
[args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user