@@ -59,7 +59,14 @@ import {
5959} from "@t3tools/contracts" ;
6060import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads" ;
6161
62- import { ChevronDownIcon , FolderIcon , ListFilterIcon , PlusIcon , SettingsIcon } from "lucide-react" ;
62+ import {
63+ CheckIcon ,
64+ ChevronDownIcon ,
65+ FolderIcon ,
66+ ListFilterIcon ,
67+ PlusIcon ,
68+ SettingsIcon ,
69+ } from "lucide-react" ;
6370import {
6471 useEffect ,
6572 useLayoutEffect ,
@@ -98,7 +105,7 @@ import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
98105import { selectProjectGroupingSettings } from "../logicalProject" ;
99106import {
100107 projectGroupsSpanEnvironments ,
101- resolveSidebarProjectScopeKey ,
108+ resolveSidebarProjectScopeKeys ,
102109 getSidebarProjectSettingsKey ,
103110 type SidebarProjectSnapshot ,
104111} from "../sidebarProjectGrouping" ;
@@ -755,6 +762,19 @@ function useSidebarLookups() {
755762 } ;
756763}
757764
765+ function ProjectScopeRowIcon ( props : { isSelected : boolean ; children : ReactNode } ) {
766+ return (
767+ < span className = "relative flex shrink-0" >
768+ { props . children }
769+ { props . isSelected ? (
770+ < span className = "pointer-events-none absolute -right-1 -bottom-1 grid size-3 place-items-center rounded-full bg-primary ring-2 ring-popover" >
771+ < CheckIcon className = "size-2 text-primary-foreground" strokeWidth = { 4 } aria-hidden />
772+ </ span >
773+ ) : null }
774+ </ span >
775+ ) ;
776+ }
777+
758778function canOperateThreads (
759779 threads : ReadonlyArray < Pick < SidebarThreadSummary , "environmentId" > > ,
760780) : boolean {
@@ -900,8 +920,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
900920 ) ;
901921 const environmentMachineById = useEnvironmentMachines ( ) ;
902922 const projectGroupsRef = useRef ( projectGroups ) ;
903- const persistedProjectScopeKey = useUiStateStore ( ( store ) => store . sidebarProjectScopeKey ) ;
904- const setPersistedProjectScopeKey = useUiStateStore ( ( store ) => store . setSidebarProjectScopeKey ) ;
923+ const persistedProjectScopeKeys = useUiStateStore ( ( store ) => store . sidebarProjectScopeKeys ) ;
924+ const setPersistedProjectScopeKeys = useUiStateStore ( ( store ) => store . setSidebarProjectScopeKeys ) ;
905925 const [ projectScopeMenuState , dispatchProjectScopeMenu ] = useReducer (
906926 reduceSidebarProjectScopeMenuState ,
907927 { open : false , query : "" } ,
@@ -961,28 +981,29 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
961981 const routeThreadKey = routeThreadRef ? scopedThreadKey ( routeThreadRef ) : null ;
962982
963983 // A selection made under one scope can hold threads the next scope hides.
964- const setProjectScopeKey = ( key : string | null ) => {
984+ const setProjectScopeKeys = ( keys : readonly string [ ] ) => {
965985 clearSelection ( ) ;
966- setPersistedProjectScopeKey ( key ) ;
986+ setPersistedProjectScopeKeys ( keys ) ;
967987 } ;
968988 // A scope whose project is gone reads as all projects once every snapshot has
969- // loaded. The stored key is left alone, so the scope returns with its project.
970- const chosenProjectScopeKey = resolveSidebarProjectScopeKey ( {
989+ // loaded. The stored keys are left alone, so the scope returns with its project.
990+ const chosenProjectScopeKeys = resolveSidebarProjectScopeKeys ( {
971991 groups : projectGroups ,
972- key : persistedProjectScopeKey ,
992+ keys : persistedProjectScopeKeys ,
973993 settings : projectGroupingSettings ,
974994 canClearMissingScope : allProjectSnapshotsReady && allProjectConfigsReady ,
975995 } ) ;
976- const chosenProjectGroup =
977- chosenProjectScopeKey === null
978- ? null
979- : ( projectGroups . find ( ( project ) => project . projectKey === chosenProjectScopeKey ) ?? null ) ;
996+ const chosenProjectGroups = projectGroups . filter ( ( project ) =>
997+ chosenProjectScopeKeys . includes ( project . projectKey ) ,
998+ ) ;
980999 const chosenScopeProjectKeys =
981- chosenProjectGroup === null
1000+ chosenProjectGroups . length === 0
9821001 ? null
9831002 : new Set (
984- chosenProjectGroup . memberProjectRefs . map (
985- ( projectRef ) => `${ projectRef . environmentId } :${ projectRef . projectId } ` ,
1003+ chosenProjectGroups . flatMap ( ( project ) =>
1004+ project . memberProjectRefs . map (
1005+ ( projectRef ) => `${ projectRef . environmentId } :${ projectRef . projectId } ` ,
1006+ ) ,
9861007 ) ,
9871008 ) ;
9881009 const threads = useQuietThreadShells ( ) ;
@@ -1013,7 +1034,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
10131034 delegatedUnderParent,
10141035 findThread : ( key ) => threads . find ( ( candidate ) => threadShellKey ( candidate ) === key ) ,
10151036 } ) ;
1016- const projectScopeKey = revealWidensScope ? null : chosenProjectScopeKey ;
1037+ const projectScopeKeys = revealWidensScope ? [ ] : chosenProjectScopeKeys ;
1038+ const projectScopeKey = projectScopeKeys . length === 0 ? null : projectScopeKeys . join ( "\n" ) ;
1039+ const toggleProjectScopeKey = ( key : string ) =>
1040+ setProjectScopeKeys (
1041+ projectScopeKeys . includes ( key )
1042+ ? projectScopeKeys . filter ( ( candidate ) => candidate !== key )
1043+ : [ ...projectScopeKeys , key ] ,
1044+ ) ;
10171045 // The widened scope outlives the reveal, so it is written back to the persisted
10181046 // scope. Route changes arrive from history, links and the palette, and only this
10191047 // component holds the project groups that decide a widening, so no single
@@ -1022,8 +1050,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
10221050 useEffect ( ( ) => {
10231051 if ( ! revealWidensScope ) return ;
10241052 clearSelection ( ) ;
1025- setPersistedProjectScopeKey ( null ) ;
1026- } , [ clearSelection , revealWidensScope , setPersistedProjectScopeKey ] ) ;
1053+ setPersistedProjectScopeKeys ( [ ] ) ;
1054+ } , [ clearSelection , revealWidensScope , setPersistedProjectScopeKeys ] ) ;
10271055 const revealActiveRow = useActiveRowReveal ( routeReveal , routeRowThreadKey ) ;
10281056 const showProjectEnvironments = projectGroupsSpanEnvironments ( projectGroups ) ;
10291057 const scratchMachineLabelFor = (
@@ -1040,26 +1068,25 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
10401068 return environmentLabelById . get ( thread . environmentId ) ?? null ;
10411069 } ;
10421070 const selectedProjectScopeItem =
1043- projectScopeItems . find ( ( item ) => item . value === ( projectScopeKey ?? "all" ) ) ??
1044- projectScopeItems [ 0 ] ! ;
1071+ projectScopeKeys . length > 1
1072+ ? null
1073+ : ( projectScopeItems . find ( ( item ) => item . value === ( projectScopeKeys [ 0 ] ?? "all" ) ) ??
1074+ projectScopeItems [ 0 ] ! ) ;
10451075 const filteredProjectScopeItems = filterSidebarProjectScopeItems ( {
10461076 items : projectScopeItems ,
10471077 query : projectScopeMenuState . query ,
10481078 matches : ( item , query ) =>
10491079 projectScopeFilter . contains ( item , query , ( candidate ) => candidate . label ) ,
10501080 } ) ;
1051- const scopedProjectGroup = revealWidensScope ? null : chosenProjectGroup ;
1081+ const scopedProjectGroups = revealWidensScope ? [ ] : chosenProjectGroups ;
1082+ const scopedProjectRefs = scopedProjectGroups . flatMap ( ( project ) => project . memberProjectRefs ) ;
10521083 const scopedProjectKeys = revealWidensScope ? null : chosenScopeProjectKeys ;
10531084 const threadSearch = useThreadSearch (
10541085 searchEnvironmentIds ,
10551086 threadSearchQuery ,
1056- scopedProjectGroup === null
1087+ scopedProjectGroups . length === 0
10571088 ? { }
1058- : {
1059- projectIds : scopedProjectGroup . memberProjectRefs . map (
1060- ( projectRef ) => projectRef . projectId ,
1061- ) ,
1062- } ,
1089+ : { projectIds : scopedProjectRefs . map ( ( projectRef ) => projectRef . projectId ) } ,
10631090 ) ;
10641091 const routeDraftIdForRows = routeTarget ?. kind === "draft" ? routeTarget . draftId : null ;
10651092 const visibleDraftSessionCount = useComposerDraftStore ( ( store ) => {
@@ -2022,6 +2049,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
20222049 threadKey,
20232050 currentThreadKey : readRouteThreadKey ( router ) ,
20242051 action : "snooze" ,
2052+ // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
20252053 now : new Date ( ) . toISOString ( ) ,
20262054 thread : readThreadShell ( threadRef ) ,
20272055 } )
@@ -2061,6 +2089,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
20612089 ) ;
20622090 if ( threadKeys . length === 0 ) return ;
20632091 const count = threadKeys . length ;
2092+ // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
20642093 const selectionNow = new Date ( ) ;
20652094 const selectedThreads = threadKeys . flatMap ( ( threadKey ) => {
20662095 const thread = threadByKeyRef . current . get ( threadKey ) ;
@@ -2094,6 +2123,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
20942123 const unpinMenuItem = buildBulkUnpinContextMenuItem ( {
20952124 pinnedCount : pinnedSelectedThreads . length ,
20962125 } ) ;
2126+ // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
20972127 const snoozePresets = resolveSnoozePresets ( new Date ( ) , timestampFormat ) ;
20982128 const clicked = await settlePromise ( ( ) =>
20992129 api . contextMenu . show (
@@ -2406,7 +2436,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
24062436 projectFilter : threadProjectGroup
24072437 ? {
24082438 label : threadProjectGroup . displayName ,
2409- isActive : projectScopeKey === threadProjectGroup . projectKey ,
2439+ isActive : projectScopeKeys . includes ( threadProjectGroup . projectKey ) ,
24102440 }
24112441 : null ,
24122442 isPinned,
@@ -2444,10 +2474,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
24442474 switch ( clicked . value ) {
24452475 case "filter-by-project" :
24462476 if ( threadProjectGroup ) {
2447- setProjectScopeKey (
2448- projectScopeKey === threadProjectGroup . projectKey
2449- ? null
2450- : threadProjectGroup . projectKey ,
2477+ setProjectScopeKeys (
2478+ projectScopeKeys . includes ( threadProjectGroup . projectKey )
2479+ ? [ ]
2480+ : [ threadProjectGroup . projectKey ] ,
24512481 ) ;
24522482 }
24532483 return ;
@@ -2732,37 +2762,59 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
27322762 itemToStringLabel = { ( item ) => item . label }
27332763 isItemEqualToValue = { ( a , b ) => a . value === b . value }
27342764 open = { projectScopeMenuState . open }
2735- onOpenChange = { ( open ) => {
2765+ onOpenChange = { ( open , eventDetails ) => {
2766+ if ( eventDetails . isCanceled ) return ;
27362767 if ( open ) suppressNextScopeChangeRef . current = false ;
27372768 dispatchProjectScopeMenu ( { type : "open-changed" , open } ) ;
27382769 } }
27392770 onItemHighlighted = { ( item ) => {
27402771 highlightedProjectScopeKeyRef . current = item ?. value ?? null ;
27412772 } }
27422773 value = { selectedProjectScopeItem }
2743- onValueChange = { ( item ) => {
2774+ onValueChange = { ( item , eventDetails ) => {
27442775 if ( suppressNextScopeChangeRef . current ) {
27452776 suppressNextScopeChangeRef . current = false ;
27462777 return ;
27472778 }
27482779 if ( ! item ) return ;
2749- setProjectScopeKey ( item . value === "all" ? null : item . value ) ;
2780+ if ( item . value === "all" ) {
2781+ setProjectScopeKeys ( [ ] ) ;
2782+ } else if (
2783+ eventDetails . event instanceof MouseEvent &&
2784+ eventDetails . event . shiftKey
2785+ ) {
2786+ // Canceling skips Base UI's commit, which would freeze the search query, and its close.
2787+ eventDetails . cancel ( ) ;
2788+ toggleProjectScopeKey ( item . value ) ;
2789+ } else {
2790+ setProjectScopeKeys ( [ item . value ] ) ;
2791+ }
27502792 } }
27512793 >
27522794 < ComboboxTrigger
27532795 render = {
27542796 < SidebarHeaderIconButton
27552797 label = {
2756- scopedProjectGroup
2757- ? `Filter threads by project: ${ scopedProjectGroup . displayName } `
2798+ scopedProjectGroups . length > 0
2799+ ? `Filter threads by project: ${ scopedProjectGroups . map ( ( project ) => project . displayName ) . join ( ", " ) } `
27582800 : "Filter threads by project"
27592801 }
27602802 />
27612803 }
27622804 >
2763- { scopedProjectGroup ? (
2805+ { scopedProjectGroups . length > 1 ? (
2806+ < span className = "flex shrink-0 -space-x-1.5" >
2807+ { scopedProjectGroups . slice ( 0 , 2 ) . map ( ( project ) => (
2808+ < ProjectFavicon
2809+ key = { project . projectKey }
2810+ project = { project }
2811+ className = "size-3.5"
2812+ />
2813+ ) ) }
2814+ </ span >
2815+ ) : scopedProjectGroups [ 0 ] ? (
27642816 < span className = "flex shrink-0" >
2765- < ProjectFavicon project = { scopedProjectGroup } className = "size-4" />
2817+ < ProjectFavicon project = { scopedProjectGroups [ 0 ] } className = "size-4" />
27662818 </ span >
27672819 ) : (
27682820 < ListFilterIcon className = "size-4" />
@@ -2781,15 +2833,27 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
27812833 if (
27822834 event . defaultPrevented ||
27832835 event . nativeEvent . isComposing ||
2836+ event . keyCode === 229 ||
27842837 event . ctrlKey ||
27852838 event . altKey ||
2786- event . metaKey ||
2787- ( event . key !== "ContextMenu" && ! ( event . shiftKey && event . key === "F10" ) )
2839+ event . metaKey
27882840 ) {
27892841 return ;
27902842 }
27912843 const scopeKey = highlightedProjectScopeKeyRef . current ;
27922844 const project = scopeKey ? projectGroupByScopeKey . get ( scopeKey ) : null ;
2845+ if ( event . shiftKey && event . key === "Enter" ) {
2846+ if ( ! project ) return ;
2847+ event . preventDefault ( ) ;
2848+ toggleProjectScopeKey ( project . projectKey ) ;
2849+ return ;
2850+ }
2851+ if (
2852+ event . key !== "ContextMenu" &&
2853+ ! ( event . shiftKey && event . key === "F10" )
2854+ ) {
2855+ return ;
2856+ }
27932857 if ( project ) handleProjectSettings ( event , project ) ;
27942858 } }
27952859 onChange = { ( event ) =>
@@ -2800,23 +2864,31 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
28002864 }
28012865 />
28022866 < ComboboxEmpty > No matching projects.</ ComboboxEmpty >
2803- < ComboboxList >
2867+ < ComboboxList aria-multiselectable >
28042868 { ( item : ( typeof projectScopeItems ) [ number ] ) => {
28052869 const project = projectGroupByScopeKey . get ( item . value ) ?? null ;
2870+ const isSelected = project
2871+ ? projectScopeKeys . includes ( item . value )
2872+ : projectScopeKeys . length === 0 ;
28062873 return (
28072874 < ComboboxItem
28082875 key = { item . value }
28092876 hideIndicator
28102877 value = { item }
2878+ aria-selected = { isSelected }
2879+ // The icon badge marks selection; Base UI's single value would shade one row.
2880+ data-selected = { undefined }
28112881 onContextMenu = { ( event ) => {
28122882 if ( project ) handleProjectSettings ( event , project ) ;
28132883 } }
28142884 >
2815- { project ? (
2816- < ProjectFavicon project = { project } className = "size-4 shrink-0" />
2817- ) : (
2818- < FolderIcon className = "size-4 shrink-0" />
2819- ) }
2885+ < ProjectScopeRowIcon isSelected = { isSelected } >
2886+ { project ? (
2887+ < ProjectFavicon project = { project } className = "size-4 shrink-0" />
2888+ ) : (
2889+ < FolderIcon className = "size-4 shrink-0" />
2890+ ) }
2891+ </ ProjectScopeRowIcon >
28202892 < span className = "min-w-0 flex-1 truncate text-sm" > { item . label } </ span >
28212893 { project && showProjectEnvironments ? (
28222894 < ProjectEnvironmentBadge
@@ -3261,8 +3333,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
32613333 Add project
32623334 </ button >
32633335 </ >
3264- ) : scopedProjectGroup ? (
3265- `No threads in ${ scopedProjectGroup . displayName } yet`
3336+ ) : scopedProjectGroups . length > 1 ? (
3337+ "No threads in these projects yet"
3338+ ) : scopedProjectGroups [ 0 ] ? (
3339+ `No threads in ${ scopedProjectGroups [ 0 ] . displayName } yet`
32663340 ) : (
32673341 "No threads yet"
32683342 ) }
0 commit comments