fix(ui): preserve upstream behavior after performance rebase

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent 9b9d7069c7
commit 532bdab5e8
34 changed files with 752 additions and 361 deletions
@@ -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 {