Date: Fri, 19 Jun 2026 18:08:36 +0200
Subject: [PATCH 011/112] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20add=20command=20pa?=
=?UTF-8?q?lette=20trigger=20on=20the=20header?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/LayoutHeader.tsx | 2 ++
src/components/LayoutMobileMenu.tsx | 33 ++++++++++---------
.../command-palette/CommandPalette.tsx | 4 +--
3 files changed, 21 insertions(+), 18 deletions(-)
diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx
index 29850f115..5e825a8e2 100644
--- a/src/components/LayoutHeader.tsx
+++ b/src/components/LayoutHeader.tsx
@@ -8,6 +8,7 @@ import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
+import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
interface LayoutHeaderProps {
showTopBar: boolean;
@@ -67,6 +68,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
{showTopBar && (
diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx
index 13efdd564..8e7d0dfc0 100644
--- a/src/components/LayoutMobileMenu.tsx
+++ b/src/components/LayoutMobileMenu.tsx
@@ -1,27 +1,27 @@
"use client";
-import React, { useState } from "react";
-import { SidebarNav } from "@app/components/SidebarNav";
-import { OrgSelector } from "@app/components/OrgSelector";
-import { cn } from "@app/lib/cn";
-import { ListUserOrgsResponse } from "@server/routers/org";
-import { Button } from "@app/components/ui/button";
-import { ArrowRight, Menu, Server } from "lucide-react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { useUserContext } from "@app/hooks/useUserContext";
-import { useTranslations } from "next-intl";
-import ProfileIcon from "@app/components/ProfileIcon";
-import ThemeSwitcher from "@app/components/ThemeSwitcher";
import type { SidebarNavSection } from "@app/app/navigation";
+import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
+import { OrgSelector } from "@app/components/OrgSelector";
+import ProfileIcon from "@app/components/ProfileIcon";
+import { SidebarNav } from "@app/components/SidebarNav";
+import ThemeSwitcher from "@app/components/ThemeSwitcher";
+import { Button } from "@app/components/ui/button";
import {
Sheet,
SheetContent,
- SheetTrigger,
+ SheetDescription,
SheetTitle,
- SheetDescription
+ SheetTrigger
} from "@app/components/ui/sheet";
-import { Abel } from "next/font/google";
+import { useUserContext } from "@app/hooks/useUserContext";
+import { cn } from "@app/lib/cn";
+import { ListUserOrgsResponse } from "@server/routers/org";
+import { Menu, Server } from "lucide-react";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useState } from "react";
interface LayoutMobileMenuProps {
orgId?: string;
@@ -121,6 +121,7 @@ export function LayoutMobileMenu({
{showTopBar && (
diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx
index 816c83fb3..514ef6e1a 100644
--- a/src/components/command-palette/CommandPalette.tsx
+++ b/src/components/command-palette/CommandPalette.tsx
@@ -100,7 +100,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
return (
{
setOpen((current) => !current);
From 8f0e17774f07292d9187f22924e3f9a85d7d8e63 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Mon, 22 Jun 2026 21:22:55 +0200
Subject: [PATCH 012/112] =?UTF-8?q?=F0=9F=92=84=20Update=20command=20bar?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../useCommandPaletteActions.tsx | 25 ++++++-------------
src/components/ui/command.tsx | 3 ++-
2 files changed, 9 insertions(+), 19 deletions(-)
diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx
index 998fb97b8..4b288c3bc 100644
--- a/src/components/command-palette/useCommandPaletteActions.tsx
+++ b/src/components/command-palette/useCommandPaletteActions.tsx
@@ -86,6 +86,12 @@ export function useCommandPaletteActions(
icon: ,
href: `/${orgId}/settings/resources/proxy/create`
});
+ actions.push({
+ id: "create-machine-client",
+ label: t("commandPaletteCreateMachineClient"),
+ icon: ,
+ href: `/${orgId}/settings/clients/machine/create`
+ });
actions.push({
id: "create-user",
label: t("commandPaletteCreateUser"),
@@ -98,12 +104,6 @@ export function useCommandPaletteActions(
icon: ,
href: `/${orgId}/settings/api-keys/create`
});
- actions.push({
- id: "create-machine-client",
- label: t("commandPaletteCreateMachineClient"),
- icon: ,
- href: `/${orgId}/settings/clients/machine/create`
- });
if (!env?.flags.disableEnterpriseFeatures) {
actions.push({
@@ -129,17 +129,6 @@ export function useCommandPaletteActions(
}
}
- const canChooseOrganization = !isAdminPage && (orgs?.length ?? 0) > 1;
-
- if (canChooseOrganization) {
- actions.push({
- id: "choose-org",
- label: t("commandPaletteChooseOrganization"),
- icon: ,
- href: "/?orgs=1"
- });
- }
-
actions.push({
id: "toggle-theme",
label: t("commandPaletteToggleTheme"),
@@ -158,4 +147,4 @@ export function useCommandPaletteActions(
return actions;
}, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]);
-}
\ No newline at end of file
+}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
index efd1c35f5..3d577c275 100644
--- a/src/components/ui/command.tsx
+++ b/src/components/ui/command.tsx
@@ -174,10 +174,11 @@ function CommandShortcut({
...props
}: React.ComponentProps<"span">) {
return (
-
Date: Thu, 25 Jun 2026 20:28:57 +0200
Subject: [PATCH 013/112] =?UTF-8?q?=F0=9F=9A=A7=20add=20country=20is=20not?=
=?UTF-8?q?=20rule?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
messages/en-US.json | 1 +
server/db/pg/schema/schema.ts | 11 ++++++++++-
server/db/sqlite/schema/schema.ts | 10 +++++++++-
server/lib/blueprints/publicResources.ts | 2 +-
server/lib/blueprints/resourcePolicies.ts | 3 ++-
server/lib/validators.ts | 2 ++
.../PolicyAccessRulesTable.tsx | 19 ++++++++++++++-----
.../policy-access-rule-validation.ts | 1 +
8 files changed, 40 insertions(+), 9 deletions(-)
diff --git a/messages/en-US.json b/messages/en-US.json
index 83e8a488d..3bb70a34f 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -2431,6 +2431,7 @@
"noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.",
"exitNode": "Exit Node",
"country": "Country",
+ "countryIsNot": "Country Is Not",
"rulesMatchCountry": "Currently based on source IP",
"region": "Region",
"selectRegion": "Select region",
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 1b48aa520..934eba1f6 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -981,7 +981,15 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
priority: integer("priority").notNull(),
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: varchar("match")
- .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
+ .$type<
+ | "CIDR"
+ | "PATH"
+ | "IP"
+ | "COUNTRY"
+ | "COUNTRY_IS_NOT"
+ | "ASN"
+ | "REGION"
+ >()
.notNull(),
value: varchar("value").notNull()
});
@@ -1553,3 +1561,4 @@ export type Label = InferSelectModel;
export type ResourcePolicy = InferSelectModel;
export type RolePolicy = InferSelectModel;
export type UserPolicy = InferSelectModel;
+export type ResourcePolicyRule = InferSelectModel;
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 0c4a143f5..76e6d22cf 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -1252,7 +1252,15 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match")
- .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
+ .$type<
+ | "CIDR"
+ | "PATH"
+ | "IP"
+ | "COUNTRY"
+ | "COUNTRY_IS_NOT"
+ | "ASN"
+ | "REGION"
+ >()
.notNull(),
value: text("value").notNull()
});
diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts
index 1bbe0a4f1..a6147ea86 100644
--- a/server/lib/blueprints/publicResources.ts
+++ b/server/lib/blueprints/publicResources.ts
@@ -1321,7 +1321,7 @@ function getRuleAction(input: string) {
function getRuleValue(match: string, value: string) {
// if the match is a country, uppercase the value
- if (match == "COUNTRY") {
+ if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
return value.toUpperCase();
}
return value;
diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts
index f8d8d1269..aa5f4bc9b 100644
--- a/server/lib/blueprints/resourcePolicies.ts
+++ b/server/lib/blueprints/resourcePolicies.ts
@@ -347,12 +347,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
function getRuleMatch(
input: string
-): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" {
+): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
+ | "COUNTRY_IS_NOT"
| "ASN"
| "REGION";
}
diff --git a/server/lib/validators.ts b/server/lib/validators.ts
index ec19bd852..ff0c5fb3d 100644
--- a/server/lib/validators.ts
+++ b/server/lib/validators.ts
@@ -74,6 +74,7 @@ export const RESOURCE_RULE_MATCH_TYPES = [
"IP",
"PATH",
"COUNTRY",
+ "COUNTRY_IS_NOT",
"ASN",
"REGION"
] as const;
@@ -96,6 +97,7 @@ export function getResourceRuleValueValidationError(
case "REGION":
return isValidRegionId(value) ? null : "Invalid region ID provided";
case "COUNTRY":
+ case "COUNTRY_IS_NOT":
return COUNTRIES.some((country) => country.code === value)
? null
: "Invalid country code provided";
diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx
index a701b92ff..2818f3652 100644
--- a/src/components/resource-policy/PolicyAccessRulesTable.tsx
+++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx
@@ -229,6 +229,7 @@ export function PolicyAccessRulesTable({
IP: "IP",
CIDR: t("ipAddressRange"),
COUNTRY: t("country"),
+ COUNTRY_IS_NOT: t("countryIsNot"),
ASN: "ASN",
REGION: t("region")
}),
@@ -441,13 +442,15 @@ export function PolicyAccessRulesTable({
| "IP"
| "PATH"
| "COUNTRY"
+ | "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
- value === "COUNTRY"
+ value === "COUNTRY" ||
+ value === "COUNTRY_IS_NOT"
? "US"
: value === "ASN"
? "AS15169"
@@ -469,9 +472,14 @@ export function PolicyAccessRulesTable({
{RuleMatch.CIDR}
{isMaxmindAvailable && (
-
- {RuleMatch.COUNTRY}
-
+ <>
+
+ {RuleMatch.COUNTRY}
+
+
+ {RuleMatch.COUNTRY_IS_NOT}
+
+ >
)}
{includeRegionMatch && isMaxmindAvailable && (
@@ -491,7 +499,8 @@ export function PolicyAccessRulesTable({
accessorKey: "value",
header: () => {t("value")},
cell: ({ row }) =>
- row.original.match === "COUNTRY" ? (
+ row.original.match === "COUNTRY" ||
+ row.original.match === "COUNTRY_IS_NOT" ? (
diff --git a/src/components/resource-launcher/LauncherFlatResourceList.tsx b/src/components/resource-launcher/LauncherFlatResourceList.tsx
new file mode 100644
index 000000000..7bbc01ccb
--- /dev/null
+++ b/src/components/resource-launcher/LauncherFlatResourceList.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
+import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
+import { launcherQueries } from "@app/lib/queries";
+import type {
+ LauncherResource,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
+import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { useEffect, useMemo, useRef } from "react";
+import { LauncherEmptyState } from "./LauncherEmptyState";
+import { LauncherResourceGrid } from "./LauncherResourceGrid";
+import { LauncherResourceList } from "./LauncherResourceList";
+
+type LauncherFlatResourceListProps = {
+ orgId: string;
+ activeViewId: LauncherActiveViewId;
+ config: LauncherViewConfig;
+ onClearFilters?: () => void;
+ onResourceSelect?: (resource: LauncherResource) => void;
+};
+
+export function LauncherFlatResourceList({
+ orgId,
+ config,
+ onClearFilters,
+ onResourceSelect
+}: LauncherFlatResourceListProps) {
+ const loadMoreRef = useRef(null);
+
+ const resourceFilters = useMemo(
+ () => ({
+ query: config.query,
+ groupBy: config.groupBy,
+ groupKey: LAUNCHER_FLAT_GROUP_KEY,
+ siteIds: config.siteIds,
+ labelIds: config.labelIds,
+ sort_by: config.sortBy,
+ order: config.order,
+ pageSize: 20
+ }),
+ [
+ config.groupBy,
+ config.labelIds,
+ config.order,
+ config.query,
+ config.siteIds,
+ config.sortBy
+ ]
+ );
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
+ useInfiniteQuery({
+ ...launcherQueries.resources(orgId, resourceFilters)
+ });
+
+ const resources = data?.pages.flatMap((page) => page.resources) ?? [];
+
+ useEffect(() => {
+ const node = loadMoreRef.current;
+ if (!node || !hasNextPage) {
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ },
+ { rootMargin: "200px" }
+ );
+
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
+
+ if (resources.length === 0) {
+ if (isFetching) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ return (
+
+ {config.layout === "grid" ? (
+
+ ) : (
+
+ )}
+
+ {isFetchingNextPage ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx
index c7fe6b905..ecf60a773 100644
--- a/src/components/resource-launcher/LauncherGroupList.tsx
+++ b/src/components/resource-launcher/LauncherGroupList.tsx
@@ -69,16 +69,20 @@ export function LauncherGroupList({
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({
...launcherQueries.groups(orgId, groupFilters),
- initialData: {
- pages: [
- {
- groups: initialGroups,
- pagination: groupsPagination
- }
- ],
- pageParams: [1]
- },
- refetchOnMount: false
+ ...(initialGroups.length > 0
+ ? {
+ initialData: {
+ pages: [
+ {
+ groups: initialGroups,
+ pagination: groupsPagination
+ }
+ ],
+ pageParams: [1]
+ },
+ refetchOnMount: false as const
+ }
+ : {})
});
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
diff --git a/src/components/resource-launcher/LauncherSearchFirstGate.tsx b/src/components/resource-launcher/LauncherSearchFirstGate.tsx
new file mode 100644
index 000000000..ab9ea1226
--- /dev/null
+++ b/src/components/resource-launcher/LauncherSearchFirstGate.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { cn } from "@app/lib/cn";
+import { Search } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+type LauncherSearchFirstGateProps = {
+ layout: "grid" | "list";
+};
+
+function GhostResourceGrid() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function GhostResourceList() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+export function LauncherSearchFirstGate({
+ layout
+}: LauncherSearchFirstGateProps) {
+ const t = useTranslations();
+
+ return (
+
+
+ {layout === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {t("resourceLauncherSearchFirstTitle")}
+
+
+ {t("resourceLauncherSearchFirstDescription")}
+
+
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx
index 41143cd10..abce2e5e6 100644
--- a/src/components/resource-launcher/LauncherSettingsMenu.tsx
+++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx
@@ -15,13 +15,19 @@ import {
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
-import type { LauncherViewConfig } from "@server/routers/launcher/types";
+import type {
+ LauncherScaleCapabilities,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
import { useTranslations } from "next-intl";
import { Settings } from "lucide-react";
type LauncherSettingsMenuProps = {
config: LauncherViewConfig;
isDefaultView: boolean;
+ capabilities: LauncherScaleCapabilities;
+ isCompactMode: boolean;
+ selectedGroupBy: LauncherViewConfig["groupBy"];
onConfigChange: (patch: Partial) => void;
onDeleteView: () => void;
};
@@ -29,6 +35,9 @@ type LauncherSettingsMenuProps = {
export function LauncherSettingsMenu({
config,
isDefaultView,
+ capabilities,
+ isCompactMode,
+ selectedGroupBy,
onConfigChange,
onDeleteView
}: LauncherSettingsMenuProps) {
@@ -51,7 +60,7 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupBy")}
+ {isCompactMode ? (
+
+ {t("resourceLauncherCompactGroupingLocked")}
+
+ ) : null}
+ {!isCompactMode && !capabilities.allowSiteGrouping ? (
+
+ {t("resourceLauncherSiteGroupingDisabled")}
+
+ ) : null}
+ {!isCompactMode && !capabilities.allowLabelGrouping ? (
+
+ {t("resourceLauncherLabelGroupingDisabled")}
+
+ ) : null}
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx
index be5620d25..2cc9d50a2 100644
--- a/src/components/resource-launcher/ResourceLauncher.tsx
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -29,12 +29,20 @@ import {
} from "@app/lib/launcherUrlState";
import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
+import {
+ getEffectiveLauncherConfig,
+ shouldShowFlatResourceList,
+ shouldShowLauncherGroupList,
+ shouldShowSearchFirstGate
+} from "@app/lib/launcherScale";
+import { launcherQueries } from "@app/lib/queries";
import type {
LauncherGroup,
+ LauncherScaleInfo,
LauncherViewConfig,
LauncherViewRecord
} from "@server/routers/launcher/types";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -52,7 +60,9 @@ import type { SelectedLabel } from "@app/components/labels-selector";
import { useMediaQuery } from "@app/hooks/useMediaQuery";
import { cn } from "@app/lib/cn";
import { LauncherFilterPopover } from "./LauncherFilterPopover";
+import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
import { LauncherGroupList } from "./LauncherGroupList";
+import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
import { LauncherRefreshButton } from "./LauncherRefreshButton";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton";
@@ -66,6 +76,7 @@ type ResourceLauncherProps = {
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
+ scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -81,6 +92,7 @@ export default function ResourceLauncher({
activeViewId,
config,
savedConfig,
+ scale: initialScale,
groups,
groupsPagination
}: ResourceLauncherProps) {
@@ -100,6 +112,38 @@ export default function ResourceLauncher({
const isDesktop = useMediaQuery("(min-width: 768px)");
+ const scaleFilters = useMemo(
+ () => ({
+ query: config.query,
+ groupBy: config.groupBy,
+ siteIds: config.siteIds,
+ labelIds: config.labelIds,
+ sort_by: config.sortBy,
+ order: config.order
+ }),
+ [
+ config.groupBy,
+ config.labelIds,
+ config.order,
+ config.query,
+ config.siteIds,
+ config.sortBy
+ ]
+ );
+
+ const { data: scale = initialScale } = useQuery({
+ ...launcherQueries.scale(orgId, scaleFilters),
+ initialData: initialScale
+ });
+
+ const showGroupList = shouldShowLauncherGroupList(scale, config);
+ const showSearchFirstGate = shouldShowSearchFirstGate(scale, config);
+ const showFlatResourceList = shouldShowFlatResourceList(scale, config);
+ const effectiveConfig = useMemo(
+ () => getEffectiveLauncherConfig(scale, config),
+ [config, scale]
+ );
+
const configRef = useRef(config);
configRef.current = config;
const searchInputRef = useRef(config.query);
@@ -451,6 +495,9 @@ export default function ResourceLauncher({
{
if (!isDefaultView) {
@@ -502,14 +549,31 @@ export default function ResourceLauncher({
)}
-
+ {scale.mode === "compact" && !showSearchFirstGate ? (
+
+ {t("resourceLauncherCompactModeHint")}
+
+ ) : null}
+
+ {showSearchFirstGate ? (
+
+ ) : showGroupList ? (
+
+ ) : showFlatResourceList ? (
+
+ ) : null}
diff --git a/src/lib/launcherLocalStorage.ts b/src/lib/launcherLocalStorage.ts
index 6e1fb60a5..1d2b64219 100644
--- a/src/lib/launcherLocalStorage.ts
+++ b/src/lib/launcherLocalStorage.ts
@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
function groupOpenKey(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label"
+ groupBy: "site" | "label" | "none"
) {
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
}
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
export function readLauncherGroupOpenState(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label"
+ groupBy: "site" | "label" | "none"
): Record {
return readJson>(
groupOpenKey(orgId, viewId, groupBy),
@@ -75,7 +75,7 @@ export function readLauncherGroupOpenState(
export function readLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label",
+ groupBy: "site" | "label" | "none",
groupKey: string,
defaultOpen: boolean
): boolean {
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
export function writeLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
- groupBy: "site" | "label",
+ groupBy: "site" | "label" | "none",
groupKey: string,
isOpen: boolean
) {
diff --git a/src/lib/launcherScale.ts b/src/lib/launcherScale.ts
new file mode 100644
index 000000000..6b086bab9
--- /dev/null
+++ b/src/lib/launcherScale.ts
@@ -0,0 +1,95 @@
+import type {
+ LauncherScaleInfo,
+ LauncherViewConfig
+} from "@server/routers/launcher/types";
+
+export function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
+ return (
+ config.query.trim().length > 0 ||
+ config.siteIds.length > 0 ||
+ config.labelIds.length > 0
+ );
+}
+
+export function getEffectiveGroupBy(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): LauncherViewConfig["groupBy"] {
+ if (scale.mode === "compact") {
+ return "none";
+ }
+
+ return config.groupBy;
+}
+
+export function getEffectiveLauncherConfig(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): LauncherViewConfig {
+ const groupBy = getEffectiveGroupBy(scale, config);
+ if (groupBy === config.groupBy) {
+ return config;
+ }
+
+ return { ...config, groupBy };
+}
+
+export function shouldFetchLauncherGroups(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): boolean {
+ const groupBy = getEffectiveGroupBy(scale, config);
+
+ if (groupBy === "none") {
+ return false;
+ }
+
+ if (scale.mode === "full") {
+ return true;
+ }
+
+ return (
+ scale.capabilities.allowSiteGrouping &&
+ groupBy === "site" &&
+ config.siteIds.length > 0
+ );
+}
+
+export function shouldShowLauncherGroupList(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): boolean {
+ return shouldFetchLauncherGroups(scale, config);
+}
+
+export function shouldShowSearchFirstGate(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): boolean {
+ return (
+ scale.mode === "compact" &&
+ scale.capabilities.requireSearchOrFilter &&
+ !hasActiveLauncherFilters(config)
+ );
+}
+
+export function shouldShowFlatResourceList(
+ scale: LauncherScaleInfo,
+ config: LauncherViewConfig
+): boolean {
+ if (shouldShowSearchFirstGate(scale, config)) {
+ return false;
+ }
+
+ const groupBy = getEffectiveGroupBy(scale, config);
+
+ if (groupBy === "none") {
+ return true;
+ }
+
+ if (scale.mode === "full") {
+ return false;
+ }
+
+ return !shouldShowLauncherGroupList(scale, config);
+}
diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts
index 2c542c5fc..994ca2ff4 100644
--- a/src/lib/launcherServerData.ts
+++ b/src/lib/launcherServerData.ts
@@ -1,12 +1,15 @@
import { internal } from "@app/lib/api";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
+import { shouldFetchLauncherGroups } from "@app/lib/launcherScale";
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type {
LauncherGroup,
+ LauncherScaleInfo,
LauncherViewConfig,
LauncherViewRecord,
ListLauncherGroupsResponse,
+ ListLauncherScaleResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import { AxiosResponse } from "axios";
@@ -16,6 +19,7 @@ export type LauncherPageData = {
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
+ scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -45,6 +49,37 @@ export async function fetchLauncherPageData(
null
);
+ const scaleFilters = {
+ query: config.query,
+ groupBy: config.groupBy,
+ siteIds: config.siteIds,
+ labelIds: config.labelIds,
+ sort_by: config.sortBy,
+ order: config.order
+ };
+
+ let scale: LauncherScaleInfo = {
+ mode: "full",
+ resourceCount: 0,
+ siteGroupCount: 0,
+ labelGroupCount: 0,
+ capabilities: {
+ allowSiteGrouping: true,
+ allowLabelGrouping: true,
+ requireSearchOrFilter: false
+ }
+ };
+
+ try {
+ const sp = buildLauncherSearchParams(scaleFilters, 1);
+ sp.delete("page");
+ sp.delete("pageSize");
+ const scaleRes = await internal.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader);
+ scale = scaleRes.data.data.scale;
+ } catch (e) {}
+
const groupFilters = {
query: config.query,
groupBy: config.groupBy,
@@ -62,20 +97,23 @@ export async function fetchLauncherPageData(
pageSize: 20
};
- try {
- const sp = buildLauncherSearchParams(groupFilters, 1);
- const groupsRes = await internal.get<
- AxiosResponse
- >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
- groups = groupsRes.data.data.groups;
- groupsPagination = groupsRes.data.data.pagination;
- } catch (e) {}
+ if (shouldFetchLauncherGroups(scale, config)) {
+ try {
+ const sp = buildLauncherSearchParams(groupFilters, 1);
+ const groupsRes = await internal.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
+ groups = groupsRes.data.data.groups;
+ groupsPagination = groupsRes.data.data.pagination;
+ } catch (e) {}
+ }
return {
views,
activeViewId,
config,
savedConfig,
+ scale,
groups,
groupsPagination
};
diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts
index cfe8e26c4..c3b2da568 100644
--- a/src/lib/launcherUrlState.ts
+++ b/src/lib/launcherUrlState.ts
@@ -113,7 +113,7 @@ function parseConfigOverrides(
}
const groupBy = searchParams.get("groupBy");
- if (groupBy === "site" || groupBy === "label") {
+ if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
overrides.groupBy = groupBy;
}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 14fa1d3da..5ca6624d6 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -50,6 +50,7 @@ import type {
ListLauncherGroupsResponse,
ListLauncherLabelsResponse,
ListLauncherResourcesResponse,
+ ListLauncherScaleResponse,
ListLauncherSitesResponse,
ListLauncherViewsResponse,
LauncherListQuery,
@@ -1298,5 +1299,19 @@ export const launcherQueries = {
const nextPage = page + 1;
return page * pageSize < total ? nextPage : undefined;
}
+ }),
+ scale: (orgId: string, filters: LauncherQueryFilters) =>
+ queryOptions({
+ queryKey: ["ORG", orgId, "LAUNCHER", "SCALE", filters] as const,
+ queryFn: async ({ signal, meta }) => {
+ const sp = buildLauncherSearchParams(filters, 1);
+ sp.delete("page");
+ sp.delete("pageSize");
+ sp.delete("groupKey");
+ const res = await meta!.api.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
+ return res.data.data.scale;
+ }
})
};
From 12f9ac94fd63ca02c7cdfcd09a40abbfdf1f2b3d Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 2 Jul 2026 22:07:30 -0400
Subject: [PATCH 023/112] allow grouping when filter applied
---
messages/en-US.json | 2 +-
server/routers/launcher/launcherScale.ts | 5 ++-
.../LauncherSettingsMenu.tsx | 12 ++++---
.../resource-launcher/ResourceLauncher.tsx | 6 ----
src/lib/launcherScale.ts | 31 +++++++++++++++----
5 files changed, 37 insertions(+), 19 deletions(-)
diff --git a/messages/en-US.json b/messages/en-US.json
index e699aba9f..fede28c11 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -3613,7 +3613,7 @@
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
- "resourceLauncherCompactGroupingLocked": "Grouping is set to None in compact mode.",
+ "resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping in compact mode.",
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name",
diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts
index ec7750cfb..5d9eb42c2 100644
--- a/server/routers/launcher/launcherScale.ts
+++ b/server/routers/launcher/launcherScale.ts
@@ -76,6 +76,7 @@ async function getLauncherScaleForUserUncached(
: "compact";
const siteFilterIds = parseIdListParam(query.siteIds);
+ const labelFilterIds = parseIdListParam(query.labelIds);
const allowSiteGrouping =
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
@@ -83,7 +84,9 @@ async function getLauncherScaleForUserUncached(
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX);
const allowLabelGrouping =
- labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS;
+ labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
+ (labelFilterIds.length > 0 &&
+ labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX);
const requireSearchOrFilter =
mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES;
diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx
index abce2e5e6..2cf2a3cbb 100644
--- a/src/components/resource-launcher/LauncherSettingsMenu.tsx
+++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx
@@ -78,8 +78,9 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupBySite")}
@@ -87,8 +88,9 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupByLabel")}
@@ -97,7 +99,7 @@ export function LauncherSettingsMenu({
{isCompactMode ? (
- {t("resourceLauncherCompactGroupingLocked")}
+ {t("resourceLauncherCompactGroupingHint")}
) : null}
{!isCompactMode && !capabilities.allowSiteGrouping ? (
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx
index 2cc9d50a2..23db498ea 100644
--- a/src/components/resource-launcher/ResourceLauncher.tsx
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -549,12 +549,6 @@ export default function ResourceLauncher({