diff --git a/package.json b/package.json
index cc29fde71..4dd50566e 100644
--- a/package.json
+++ b/package.json
@@ -52,8 +52,8 @@
"@rjsf/utils": "5.24.10",
"@rjsf/validator-ajv8": "5.24.10",
"@sentry/nextjs": "8.55.0",
- "@squonk/account-server-client": "4.2.0-rc.8",
- "@squonk/data-manager-client": "4.0.0-rc.1",
+ "@squonk/account-server-client": "4.2.1",
+ "@squonk/data-manager-client": "4.1.0",
"@squonk/mui-theme": "5.0.0",
"@squonk/sdf-parser": "1.3.1",
"@tanstack/match-sorter-utils": "8.19.4",
@@ -67,6 +67,7 @@
"@types/prismjs": "1.26.5",
"@types/react": "19.1.4",
"@types/react-plotly.js": "2.6.3",
+ "@types/semver": "^7.7.0",
"axios": "1.9.0",
"dayjs": "1.11.13",
"filesize": "10.1.6",
@@ -93,6 +94,7 @@
"react-dropzone": "14.3.8",
"react-plotly.js": "2.6.0",
"react-use-websocket": "4.13.0",
+ "semver": "^7.7.2",
"sharp": "0.34.1",
"typescript": "5.8.3",
"use-immer": "0.11.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bb7e28804..6f0abec6a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -68,11 +68,11 @@ importers:
specifier: 8.55.0
version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.83.1))(react@19.1.0)(webpack@5.97.1)
'@squonk/account-server-client':
- specifier: 4.2.0-rc.8
- version: 4.2.0-rc.8(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)
+ specifier: 4.2.1
+ version: 4.2.1(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)
'@squonk/data-manager-client':
- specifier: 4.0.0-rc.1
- version: 4.0.0-rc.1(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)
+ specifier: 4.1.0
+ version: 4.1.0(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)
'@squonk/mui-theme':
specifier: 5.0.0
version: 5.0.0(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
@@ -112,6 +112,9 @@ importers:
'@types/react-plotly.js':
specifier: 2.6.3
version: 2.6.3
+ '@types/semver':
+ specifier: ^7.7.0
+ version: 7.7.0
axios:
specifier: 1.9.0
version: 1.9.0
@@ -190,6 +193,9 @@ importers:
react-use-websocket:
specifier: 4.13.0
version: 4.13.0
+ semver:
+ specifier: ^7.7.2
+ version: 7.7.2
sharp:
specifier: 0.34.1
version: 0.34.1
@@ -1584,14 +1590,14 @@ packages:
'@sideway/pinpoint@2.0.0':
resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
- '@squonk/account-server-client@4.2.0-rc.8':
- resolution: {integrity: sha512-CBB5chY+MzaCcAHJ6VAnHltuvA/OiP1C+DksH0qnSzlqdWL1Ey0nu/RrUnVq/iF3wLUlidT6Un/K43eI+B3UkA==}
+ '@squonk/account-server-client@4.2.1':
+ resolution: {integrity: sha512-lIqpPqrhAR95hBb05dV5H5KO3/VDb6tHZm60c101v1XqT4KuUydEsc5PUW79yYXgIclWOUVwTcOfiMGHegSeag==}
peerDependencies:
'@tanstack/react-query': '>=4'
axios: '>=0.23'
- '@squonk/data-manager-client@4.0.0-rc.1':
- resolution: {integrity: sha512-Ahig16tGl8jjKlXMla91hEVthlUmspoEj6bcXZ9xvOBstPr0SAYvjCyI6a30mmaGSSNiLq181v0YKQXK2TexDg==}
+ '@squonk/data-manager-client@4.1.0':
+ resolution: {integrity: sha512-QYeKqw+UrbkHo5l44glQgL9DrU7VnnqplPRmt5rTdAQX8vFdYbH21te1ZUXpDqH/eF62zv47DoPcAPWSBLLfVQ==}
peerDependencies:
'@tanstack/react-query': '>=4'
axios: '>=0.23'
@@ -1812,6 +1818,9 @@ packages:
'@types/react@19.1.4':
resolution: {integrity: sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==}
+ '@types/semver@7.7.0':
+ resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
+
'@types/shimmer@1.2.0':
resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==}
@@ -5251,8 +5260,8 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
- semver@7.7.1:
- resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==}
+ semver@7.7.2:
+ resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
hasBin: true
@@ -6816,7 +6825,7 @@ snapshots:
ini: 4.1.3
nopt: 7.2.1
proc-log: 4.2.0
- semver: 7.7.1
+ semver: 7.7.2
walk-up-path: 3.0.1
transitivePeerDependencies:
- bluebird
@@ -6830,7 +6839,7 @@ snapshots:
proc-log: 4.2.0
promise-inflight: 1.0.1
promise-retry: 2.0.1
- semver: 7.7.1
+ semver: 7.7.2
which: 4.0.0
transitivePeerDependencies:
- bluebird
@@ -6852,7 +6861,7 @@ snapshots:
json-parse-even-better-errors: 3.0.2
normalize-package-data: 6.0.2
proc-log: 4.2.0
- semver: 7.7.1
+ semver: 7.7.2
transitivePeerDependencies:
- bluebird
@@ -6965,7 +6974,7 @@ snapshots:
'@opentelemetry/instrumentation': 0.57.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.28.0
forwarded-parse: 2.1.2
- semver: 7.7.1
+ semver: 7.7.2
transitivePeerDependencies:
- supports-color
@@ -7098,7 +7107,7 @@ snapshots:
'@types/shimmer': 1.2.0
import-in-the-middle: 1.11.3
require-in-the-middle: 7.4.0
- semver: 7.7.1
+ semver: 7.7.2
shimmer: 1.2.1
transitivePeerDependencies:
- supports-color
@@ -7110,7 +7119,7 @@ snapshots:
'@types/shimmer': 1.2.0
import-in-the-middle: 1.11.3
require-in-the-middle: 7.4.0
- semver: 7.7.1
+ semver: 7.7.2
shimmer: 1.2.1
transitivePeerDependencies:
- supports-color
@@ -7122,7 +7131,7 @@ snapshots:
'@types/shimmer': 1.2.0
import-in-the-middle: 1.11.3
require-in-the-middle: 7.4.0
- semver: 7.7.1
+ semver: 7.7.2
shimmer: 1.2.1
transitivePeerDependencies:
- supports-color
@@ -7573,12 +7582,12 @@ snapshots:
'@sideway/pinpoint@2.0.0': {}
- '@squonk/account-server-client@4.2.0-rc.8(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)':
+ '@squonk/account-server-client@4.2.1(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)':
dependencies:
'@tanstack/react-query': 5.76.1(react@19.1.0)
axios: 1.9.0
- '@squonk/data-manager-client@4.0.0-rc.1(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)':
+ '@squonk/data-manager-client@4.1.0(@tanstack/react-query@5.76.1(react@19.1.0))(axios@1.9.0)':
dependencies:
'@tanstack/react-query': 5.76.1(react@19.1.0)
axios: 1.9.0
@@ -7850,6 +7859,8 @@ snapshots:
dependencies:
csstype: 3.1.3
+ '@types/semver@7.7.0': {}
+
'@types/shimmer@1.2.0': {}
'@types/supercluster@7.1.3':
@@ -7921,7 +7932,7 @@ snapshots:
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
- semver: 7.7.1
+ semver: 7.7.2
ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3
transitivePeerDependencies:
@@ -8609,7 +8620,7 @@ snapshots:
postcss-modules-scope: 3.2.1(postcss@8.5.3)
postcss-modules-values: 4.0.0(postcss@8.5.3)
postcss-value-parser: 4.2.0
- semver: 7.7.1
+ semver: 7.7.2
optionalDependencies:
webpack: 5.97.1
@@ -9210,7 +9221,7 @@ snapshots:
get-tsconfig: 4.10.0
is-glob: 4.0.3
minimatch: 10.0.1
- semver: 7.7.1
+ semver: 7.7.2
stable-hash: 0.0.5
tslib: 2.8.1
unrs-resolver: 1.6.4
@@ -9285,7 +9296,7 @@ snapshots:
read-package-up: 11.0.0
regexp-tree: 0.1.27
regjsparser: 0.12.0
- semver: 7.7.1
+ semver: 7.7.2
strip-indent: 4.0.0
eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.31.0(@typescript-eslint/parser@8.31.0(eslint@9.25.1)(typescript@5.8.3))(eslint@9.25.1)(typescript@5.8.3))(eslint@9.25.1):
@@ -10078,7 +10089,7 @@ snapshots:
is-bun-module@2.0.0:
dependencies:
- semver: 7.7.1
+ semver: 7.7.2
is-callable@1.2.7: {}
@@ -11166,7 +11177,7 @@ snapshots:
normalize-package-data@6.0.2:
dependencies:
hosted-git-info: 7.0.2
- semver: 7.7.1
+ semver: 7.7.2
validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {}
@@ -11188,7 +11199,7 @@ snapshots:
npm-install-checks@6.3.0:
dependencies:
- semver: 7.7.1
+ semver: 7.7.2
npm-normalize-package-bin@3.0.1: {}
@@ -11196,7 +11207,7 @@ snapshots:
dependencies:
hosted-git-info: 7.0.2
proc-log: 4.2.0
- semver: 7.7.1
+ semver: 7.7.2
validate-npm-package-name: 5.0.1
npm-pick-manifest@9.1.0:
@@ -11204,7 +11215,7 @@ snapshots:
npm-install-checks: 6.3.0
npm-normalize-package-bin: 3.0.1
npm-package-arg: 11.0.3
- semver: 7.7.1
+ semver: 7.7.2
npm-run-path@5.3.0:
dependencies:
@@ -12083,7 +12094,7 @@ snapshots:
semver@6.3.1: {}
- semver@7.7.1: {}
+ semver@7.7.2: {}
serialize-javascript@6.0.2:
dependencies:
@@ -12121,7 +12132,7 @@ snapshots:
dependencies:
color: 4.2.3
detect-libc: 2.0.3
- semver: 7.7.1
+ semver: 7.7.2
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.1
'@img/sharp-darwin-x64': 0.34.1
diff --git a/src/components/BaseCard.tsx b/src/components/BaseCard.tsx
index 219890968..6604802a3 100644
--- a/src/components/BaseCard.tsx
+++ b/src/components/BaseCard.tsx
@@ -86,14 +86,13 @@ export const BaseCard = ({
/>
)}
{children}
-
+
{/* ? should this be a functionCall() or a
or should this be separate props with a union and one a never type */}
{typeof actions === "function" ? actions({ setExpanded }) : actions}
{collapsed !== undefined && (
({
marginLeft: "auto",
transform: `rotate(${expanded ? 180 : 0}deg)`,
diff --git a/src/components/SearchTextField.tsx b/src/components/SearchTextField.tsx
index dafb8a717..b0b311861 100644
--- a/src/components/SearchTextField.tsx
+++ b/src/components/SearchTextField.tsx
@@ -1,21 +1,30 @@
+import { forwardRef } from "react";
+
import { SearchRounded as SearchRoundedIcon } from "@mui/icons-material";
import { InputAdornment, TextField, type TextFieldProps } from "@mui/material";
+import { getSearchShortcut } from "../utils/platform";
+
/**
- * MuiTextField with a search icon at the end
+ * MuiTextField with a search icon at the end and platform-specific keyboard shortcut in label
*/
-export const SearchTextField = (TextFieldProps: TextFieldProps) => (
-
-
-
- ),
- },
- }}
- />
+export const SearchTextField = forwardRef(
+ (TextFieldProps, ref) => (
+
+
+
+ ),
+ },
+ }}
+ />
+ ),
);
+
+SearchTextField.displayName = "SearchTextField";
diff --git a/src/components/runCards/JobCard/JobCard.tsx b/src/components/runCards/JobCard/JobCard.tsx
index a662d48bb..9001cd830 100644
--- a/src/components/runCards/JobCard/JobCard.tsx
+++ b/src/components/runCards/JobCard/JobCard.tsx
@@ -1,13 +1,21 @@
+import { useState } from "react";
+
import { type JobSummary } from "@squonk/data-manager-client";
-import { Alert, Chip, LinearProgress, Link, Typography } from "@mui/material";
+import { Alert, Chip, LinearProgress, Link, MenuItem, TextField, Typography } from "@mui/material";
import dynamic from "next/dynamic";
+import semver from "semver";
import { BaseCard } from "../../BaseCard";
import { Chips } from "../../Chips";
import { type InstancesListProps } from "../InstancesList";
import { RunJobButton, type RunJobButtonProps } from "./RunJobButton";
+const compareJobs = (a: JobSummary, b: JobSummary) => {
+ return -semver.compare(a.version, b.version);
+};
+
+
const InstancesList = dynamic(
() => import("../InstancesList").then((mod) => mod.InstancesList),
{ loading: () => },
@@ -15,9 +23,9 @@ const InstancesList = dynamic(
export interface ApplicationCardProps extends Pick {
/**
- * the job to be instantiated
+ * the list of jobs (different versions) to be instantiated
*/
- job: JobSummary;
+ job: JobSummary[];
/**
* Whether to disable the button
*/
@@ -28,16 +36,37 @@ export interface ApplicationCardProps extends Pick {
+export const JobCard = ({ projectId, job: jobs, disabled = false }: ApplicationCardProps) => {
+ jobs.sort(compareJobs);
+ const [selectedJobId, setSelectedJobId] = useState(jobs[0]?.id || "");
+ const job = jobs.find(j => j.id === selectedJobId) as JobSummary;
+
return (
(
- setExpanded(true)}
- />
+ <>
+ setSelectedJobId(e.target.value)}
+ >
+ {jobs.map((jobVersion) => (
+
+ ))}
+
+ setExpanded(true)}
+ />
+ >
)}
collapsed={
{
diff --git a/src/hooks/useKeyboardFocus.ts b/src/hooks/useKeyboardFocus.ts
new file mode 100644
index 000000000..c5fc55d3a
--- /dev/null
+++ b/src/hooks/useKeyboardFocus.ts
@@ -0,0 +1,26 @@
+import { useEffect, useRef } from "react";
+
+import { isMac } from "../utils/platform";
+
+/**
+ * Hook that provides a ref and keyboard shortcut (Ctrl+F/Cmd+F) to focus an input field
+ */
+export const useKeyboardFocus = () => {
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const isCorrectModifier = isMac() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
+
+ if (isCorrectModifier && event.key === 'f') {
+ event.preventDefault();
+ inputRef.current?.querySelector('input')?.focus();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, []);
+
+ return inputRef;
+};
diff --git a/src/hooks/useSearchFieldFocus.ts b/src/hooks/useSearchFieldFocus.ts
new file mode 100644
index 000000000..1c2ea71bc
--- /dev/null
+++ b/src/hooks/useSearchFieldFocus.ts
@@ -0,0 +1,22 @@
+import { useEffect, useRef } from "react";
+
+/**
+ * Hook that provides a ref and keyboard shortcut (Ctrl+F/Cmd+F) to focus an input field
+ */
+export const useKeyboardFocus = () => {
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
+ event.preventDefault();
+ inputRef.current?.querySelector('input')?.focus();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, []);
+
+ return inputRef;
+};
diff --git a/src/pages/run.tsx b/src/pages/run.tsx
index 2df910a3a..ea830443a 100644
--- a/src/pages/run.tsx
+++ b/src/pages/run.tsx
@@ -1,10 +1,12 @@
-import { useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { useGetApplications } from "@squonk/data-manager-client/application";
import { useGetJobs } from "@squonk/data-manager-client/job";
import { withPageAuthRequired as withPageAuthRequiredCSR } from "@auth0/nextjs-auth0/client";
import { Alert, Container, Grid2 as Grid, MenuItem, TextField } from "@mui/material";
+import groupBy from "just-group-by";
+import { debounce } from "lodash-es";
import dynamic from "next/dynamic";
import Head from "next/head";
@@ -16,6 +18,7 @@ import { TEST_JOB_ID } from "../components/runCards/TestJob/jobId";
import { SearchTextField } from "../components/SearchTextField";
import { AS_ROLES, DM_ROLES } from "../constants/auth";
import { useCurrentProject, useIsUserAdminOrEditorOfCurrentProject } from "../hooks/projectHooks";
+import { useKeyboardFocus } from "../hooks/useKeyboardFocus";
import Layout from "../layouts/Layout";
import { search } from "../utils/app/searches";
@@ -23,13 +26,26 @@ const TestJobCard = dynamic(
() => import("../components/runCards/TestJob/TestJobCard").then((mod) => mod.TestJobCard),
{ loading: () => },
);
-
/**
* Page allowing the user to run jobs and applications
*/
const Run = () => {
const [executionTypes, setExecutionTypes] = useState(["application", "job"]);
const [searchValue, setSearchValue] = useState("");
+ const [debouncedSearchValue, setDebouncedSearchValue] = useState("");
+ const inputRef = useKeyboardFocus();
+
+ // Create debounced search function
+ const debouncedSetSearch = useMemo(
+ () => debounce((value: string) => setDebouncedSearchValue(value), 300),
+ []
+ );
+
+ // Update debounced value when search value changes
+ useEffect(() => {
+ debouncedSetSearch(searchValue);
+ return () => debouncedSetSearch.cancel();
+ }, [searchValue, debouncedSetSearch]);
const currentProject = useCurrentProject();
@@ -44,41 +60,54 @@ const Run = () => {
const applications = applicationsData?.applications;
const {
- data: jobsData,
+ data: jobs,
isLoading: isJobsLoading,
isError: isJobsError,
error: jobsError,
- } = useGetJobs({ project_id: currentProject?.project_id });
- const jobs = jobsData?.jobs;
+ } = useGetJobs(
+ { project_id: currentProject?.project_id },
+ { query: { select: (data) => data.jobs } },
+ );
+
+ // Memoize filtered applications
+ const filteredApplications = useMemo(() => {
+ if (!applications) {return [];}
+ return applications.filter(({ kind }) => search([kind], debouncedSearchValue));
+ }, [applications, debouncedSearchValue]);
+
+ // Memoize filtered and grouped jobs
+ const filteredAndGroupedJobs = useMemo(() => {
+ if (!jobs) {return {};}
+ const filteredJobs = jobs
+ .filter(({ keywords, category, name, job, description }) =>
+ search([keywords, category, name, job, description], debouncedSearchValue),
+ )
+ .filter(job => !job.replaced_by);
+
+ return groupBy(filteredJobs, (job) => `${job.collection}+${job.job}`);
+ }, [jobs, debouncedSearchValue]);
+
+ // Memoize event handlers
+ const handleSearchChange = useCallback((event: React.ChangeEvent) => {
+ setSearchValue(event.target.value);
+ }, []);
+
+ const handleExecutionTypesChange = useCallback((event: any) => {
+ setExecutionTypes(event.target.value as string[]);
+ }, []);
const cards = useMemo(() => {
- const applicationCards =
- applications
- // Filter the apps by the search value
- ?.filter(({ kind }) => search([kind], searchValue))
- // Then create a card for each
- .map((app) => (
-
-
-
- )) ?? [];
-
- // Filter the apps by the search value
- const filteredJobs = jobs?.filter(({ keywords, category, name, job, description }) =>
- search([keywords, category, name, job, description], searchValue),
- );
-
- // Then create a card for each
- const jobCards =
- filteredJobs?.map((job) => (
-
-
-
- )) ?? [];
+ const applicationCards = filteredApplications.map((app) => (
+
+
+
+ ));
+
+ const jobCards = Object.entries(filteredAndGroupedJobs).map(([key, jobs]) => (
+
+
+
+ ));
process.env.NODE_ENV === "development" && jobCards.push();
@@ -92,11 +121,10 @@ const Run = () => {
}
return jobCards;
}, [
- applications,
+ filteredApplications,
+ filteredAndGroupedJobs,
currentProject?.project_id,
executionTypes,
- jobs,
- searchValue,
hasPermissionToRun,
]);
@@ -119,9 +147,7 @@ const Run = () => {
slotProps={{
select: {
multiple: true,
- onChange: (event) => {
- setExecutionTypes(event.target.value as string[]);
- },
+ onChange: handleExecutionTypesChange,
},
}}
value={executionTypes}
@@ -135,8 +161,9 @@ const Run = () => {
setSearchValue(event.target.value)}
+ onChange={handleSearchChange}
/>
diff --git a/src/utils/platform.ts b/src/utils/platform.ts
new file mode 100644
index 000000000..725884a0a
--- /dev/null
+++ b/src/utils/platform.ts
@@ -0,0 +1,26 @@
+/**
+ * Detects if the current platform is macOS using modern APIs
+ */
+export const isMac = () => {
+ if (typeof navigator === 'undefined') {
+ return false;
+ }
+
+ // Modern approach using userAgentData (Chrome 90+)
+ if ('userAgentData' in navigator) {
+ const userAgentData = (navigator as any).userAgentData;
+ if (userAgentData?.platform) {
+ return userAgentData.platform === 'macOS';
+ }
+ }
+
+ // Fallback to userAgent string parsing
+ return (/Mac|iPhone|iPad|iPod/u).test(navigator.userAgent);
+};
+
+/**
+ * Returns the platform-specific keyboard shortcut text for search
+ */
+export const getSearchShortcut = () => {
+ return isMac() ? '⌘F' : 'Ctrl+F';
+};