feat: add command palette entity search

This commit is contained in:
Amruth Pillai
2026-07-06 00:29:06 +02:00
parent 0a64312bf8
commit fb9c217af2
59 changed files with 2044 additions and 49 deletions
@@ -1,11 +1,13 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
BriefcaseIcon,
ChatCircleDotsIcon,
GearIcon,
HouseSimpleIcon,
KeyIcon,
OpenAiLogoIcon,
PlusIcon,
ReadCvLogoIcon,
ShieldCheckIcon,
UserCircleIcon,
@@ -47,12 +49,45 @@ export function NavigationCommandGroup() {
<CommandItem
disabled={!session}
keywords={[t`Agent`, t`Artificial Intelligence`]}
value="navigation.agent"
keywords={[t`Applications`, t`Jobs`]}
value="navigation.applications"
onSelect={() => onNavigate("/dashboard/applications")}
>
<BriefcaseIcon />
<Trans>Applications</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`New Application`, t`Add application`, t`Job`]}
value="navigation.applications.new"
onSelect={async () => {
await navigate({ to: "/dashboard/applications", search: { create: true } });
reset();
}}
>
<PlusIcon />
<Trans>New Application</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Threads`, t`Agent`, t`Artificial Intelligence`]}
value="navigation.threads"
onSelect={() => onNavigate("/agent")}
>
<ChatCircleDotsIcon />
<Trans>Agent</Trans>
<Trans>Threads</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`New Thread`, t`Agent`, t`Artificial Intelligence`]}
value="navigation.threads.new"
onSelect={() => onNavigate("/agent/new")}
>
<PlusIcon />
<Trans>New Thread</Trans>
</CommandItem>
<CommandItem
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
@@ -8,32 +8,53 @@ import { useQuery } from "@tanstack/react-query";
import { Command, CommandList } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../store";
const queryOptionsMock = vi.fn((options?: unknown) => options ?? {});
const mocks = vi.hoisted(() => ({
pathname: "/",
resumeQueryOptions: vi.fn((options?: unknown) => ({ entity: "resumes", ...(options ?? {}) })),
applicationsListQueryOptions: vi.fn(() => ({ entity: "applications" })),
threadsQueryOptions: vi.fn((options?: unknown) => ({ entity: "threads", ...(options ?? {}) })),
navigate: vi.fn(),
openDialog: vi.fn(),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(),
}));
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => vi.fn(),
useNavigate: () => mocks.navigate,
useRouteContext: () => ({ session: { user: { id: "user-1" } } }),
useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) =>
select({ location: { pathname: mocks.pathname } }),
}));
vi.mock("@/dialogs/store", () => ({
useDialogStore: () => ({ openDialog: vi.fn() }),
useDialogStore: () => ({ openDialog: mocks.openDialog }),
}));
vi.mock("@/features/applications/queries", () => ({
applicationsListQueryOptions: mocks.applicationsListQueryOptions,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
list: {
queryOptions: queryOptionsMock,
queryOptions: mocks.resumeQueryOptions,
},
},
agent: {
threads: {
list: {
queryOptions: mocks.threadsQueryOptions,
},
},
},
},
}));
const { ResumesCommandGroup } = await import("./resumes");
const { NavigationCommandGroup } = await import("./navigation");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -41,9 +62,16 @@ beforeAll(() => {
afterEach(() => {
vi.clearAllMocks();
mocks.pathname = "/";
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const mockUseQueryData = (getData: (entity: string | undefined) => unknown) => {
vi.mocked(useQuery).mockImplementation(((options: unknown) => {
return { data: getData((options as { entity?: string }).entity), isLoading: false } as ReturnType<typeof useQuery>;
}) as typeof useQuery);
};
const renderGroup = () =>
render(
<I18nProvider i18n={i18n}>
@@ -57,18 +85,151 @@ const renderGroup = () =>
describe("ResumesCommandGroup", () => {
it("loads resumes with the default list input on the resumes page", () => {
useCommandPaletteStore.setState({ pages: ["resumes"] });
vi.mocked(useQuery).mockReturnValue({
data: [{ id: "resume-1", name: "Evil Apricot Pike" }],
isLoading: false,
} as ReturnType<typeof useQuery>);
mocks.pathname = "/dashboard/resumes";
mockUseQueryData((entity) =>
entity === "resumes" ? [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }] : [],
);
renderGroup();
expect(queryOptionsMock).toHaveBeenCalledWith({
expect(mocks.resumeQueryOptions).toHaveBeenCalledWith({
enabled: true,
input: { sort: "lastUpdatedAt", tags: [] },
});
expect(screen.getByText("Evil Apricot Pike")).toBeInTheDocument();
});
it("filters resume results from the command palette search", () => {
mocks.pathname = "/dashboard/resumes";
useCommandPaletteStore.setState({ search: "apri" });
mockUseQueryData((entity) =>
entity === "resumes"
? [
{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" },
{ id: "resume-2", name: "Quiet Banana", slug: "banana" },
]
: [],
);
renderGroup();
expect(screen.getByText("Evil Apricot Pike")).toBeInTheDocument();
expect(screen.queryByText("Quiet Banana")).not.toBeInTheDocument();
});
it("loads applications on the applications page", () => {
mocks.pathname = "/dashboard/applications";
mockUseQueryData((entity) =>
entity === "applications"
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
: [],
);
renderGroup();
expect(screen.getByText("Umbrella")).toBeInTheDocument();
expect(screen.getByText("Staff Engineer")).toBeInTheDocument();
});
it("filters application results from the command palette search", () => {
mocks.pathname = "/dashboard/applications";
useCommandPaletteStore.setState({ search: "umbrella" });
mockUseQueryData((entity) =>
entity === "applications"
? [
{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false },
{ id: "application-2", company: "Wayne Enterprises", role: "Product Engineer", archived: false },
]
: [],
);
renderGroup();
expect(screen.getByText("Umbrella")).toBeInTheDocument();
expect(screen.queryByText("Wayne Enterprises")).not.toBeInTheDocument();
});
it("opens the selected application by id", () => {
mocks.pathname = "/dashboard/applications";
mockUseQueryData((entity) =>
entity === "applications"
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
: [],
);
renderGroup();
fireEvent.click(screen.getByText("Umbrella"));
expect(mocks.navigate).toHaveBeenCalledWith({
to: "/dashboard/applications",
search: { applicationId: "application-1" },
});
});
it("loads threads on agent pages", () => {
mocks.pathname = "/agent";
mockUseQueryData((entity) =>
entity === "threads" ? [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }] : [],
);
renderGroup();
expect(screen.getByText("Cover letter rewrite")).toBeInTheDocument();
expect(screen.getByText("Product Resume")).toBeInTheDocument();
});
it("filters thread results from the command palette search", () => {
mocks.pathname = "/agent";
useCommandPaletteStore.setState({ search: "cover" });
mockUseQueryData((entity) =>
entity === "threads"
? [
{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" },
{ id: "thread-2", title: "Resume cleanup", resumeName: "Staff Resume" },
]
: [],
);
renderGroup();
expect(screen.getByText("Cover letter rewrite")).toBeInTheDocument();
expect(screen.queryByText("Resume cleanup")).not.toBeInTheDocument();
});
});
describe("NavigationCommandGroup", () => {
it("shows application and thread navigation items", () => {
render(
<I18nProvider i18n={i18n}>
<Command>
<CommandList>
<NavigationCommandGroup />
</CommandList>
</Command>
</I18nProvider>,
);
expect(screen.getByText("Applications")).toBeInTheDocument();
expect(screen.getByText("New Application")).toBeInTheDocument();
expect(screen.getByText("Threads")).toBeInTheDocument();
expect(screen.getByText("New Thread")).toBeInTheDocument();
});
it("navigates to new application and new thread destinations", () => {
render(
<I18nProvider i18n={i18n}>
<Command>
<CommandList>
<NavigationCommandGroup />
</CommandList>
</Command>
</I18nProvider>,
);
fireEvent.click(screen.getByText("New Application"));
expect(mocks.navigate).toHaveBeenCalledWith({ to: "/dashboard/applications", search: { create: true } });
fireEvent.click(screen.getByText("New Thread"));
expect(mocks.navigate).toHaveBeenCalledWith({ to: "/agent/new" });
});
});
@@ -1,32 +1,73 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PlusIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
import { BriefcaseIcon, ChatCircleDotsIcon, PlusIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useRouteContext } from "@tanstack/react-router";
import { useNavigate, useRouteContext, useRouterState } from "@tanstack/react-router";
import { CommandLoading } from "cmdk";
import { CommandItem, CommandShortcut } from "@reactive-resume/ui/components/command";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import { useDialogStore } from "@/dialogs/store";
import { applicationsListQueryOptions } from "@/features/applications/queries";
import { orpc } from "@/libs/orpc/client";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
type Application = RouterOutput["applications"]["list"][number];
type Thread = RouterOutput["agent"]["threads"]["list"][number];
type SearchPage = "resumes" | "applications" | "threads";
const isSearchPage = (page: string | undefined): page is SearchPage =>
page === "resumes" || page === "applications" || page === "threads";
const getRouteSearchPage = (pathname: string): SearchPage | undefined => {
if (pathname.startsWith("/dashboard/resumes")) return "resumes";
if (pathname.startsWith("/dashboard/applications")) return "applications";
if (pathname.startsWith("/agent")) return "threads";
};
const matchesSearch = (search: string, values: Array<string | null | undefined>) => {
const query = search.trim().toLowerCase();
return !query || values.some((value) => value?.toLowerCase().includes(query));
};
export function ResumesCommandGroup() {
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const { session } = useRouteContext({ strict: false });
const pathname = useRouterState({ select: (state) => state.location.pathname });
const reset = useCommandPaletteStore((state) => state.reset);
const peekPage = useCommandPaletteStore((state) => state.peekPage);
const pushPage = useCommandPaletteStore((state) => state.pushPage);
const search = useCommandPaletteStore((state) => state.search);
const isResumesPage = peekPage() === "resumes";
const commandPage = peekPage();
const commandSearchPage = isSearchPage(commandPage) ? commandPage : undefined;
const searchPage = commandSearchPage ?? (!commandPage ? getRouteSearchPage(pathname) : undefined);
const { data: resumes, isLoading } = useQuery(
orpc.resume.list.queryOptions({
enabled: !!session && isResumesPage,
enabled: !!session && searchPage === "resumes",
input: { sort: "lastUpdatedAt", tags: [] },
}),
);
const { data: applications, isLoading: isLoadingApplications } = useQuery({
...applicationsListQueryOptions(),
enabled: !!session && searchPage === "applications",
});
const { data: threads, isLoading: isLoadingThreads } = useQuery(
orpc.agent.threads.list.queryOptions({
enabled: !!session && searchPage === "threads",
}),
);
const filteredResumes = (resumes ?? []).filter((resume) => matchesSearch(search, [resume.name, resume.slug]));
const filteredApplications = (applications ?? []).filter((application) =>
matchesSearch(search, [application.company, application.role]),
);
const filteredThreads = (threads ?? []).filter((thread) =>
matchesSearch(search, [thread.title, thread.resumeName, thread.providerLabel]),
);
const onCreate = async () => {
await navigate({ to: "/dashboard/resumes" });
@@ -39,6 +80,24 @@ export function ResumesCommandGroup() {
reset();
};
const onCreateApplication = async () => {
await navigate({ to: "/dashboard/applications", search: { create: true } });
reset();
};
const onOpenApplication = async (application: Application) => {
await navigate({
to: "/dashboard/applications",
search: { applicationId: application.id },
});
reset();
};
const onOpenThread = async (thread: Thread) => {
await navigate({ to: "/agent/$threadId", params: { threadId: thread.id } });
reset();
};
if (!session) return null;
return (
@@ -48,38 +107,112 @@ export function ResumesCommandGroup() {
<ReadCvLogoIcon />
<Trans>Resumes</Trans>
</CommandItem>
</BaseCommandGroup>
<BaseCommandGroup page="resumes" heading={<Trans>Resumes</Trans>}>
<CommandItem onSelect={onCreate}>
<PlusIcon />
<Trans>Create a new resume</Trans>
<CommandItem keywords={[t`Applications`]} value="search.applications" onSelect={() => pushPage("applications")}>
<BriefcaseIcon />
<Trans>Applications</Trans>
</CommandItem>
{isLoading ? (
<CommandLoading>
<Trans>Loading resumes</Trans>
</CommandLoading>
) : (
resumes?.map((resume) => (
<CommandItem
key={resume.id}
value={resume.id}
keywords={[resume.name]}
onSelect={() => onNavigate(`/builder/${resume.id}`)}
>
<ReadCvLogoIcon />
{resume.name}
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
Press <Kbd>Enter</Kbd> to open
</Trans>
</CommandShortcut>
</CommandItem>
))
)}
<CommandItem keywords={[t`Threads`, t`Agent`]} value="search.threads" onSelect={() => pushPage("threads")}>
<ChatCircleDotsIcon />
<Trans>Threads</Trans>
</CommandItem>
</BaseCommandGroup>
{searchPage === "resumes" ? (
<BaseCommandGroup page={commandSearchPage} heading={<Trans>Resumes</Trans>}>
<CommandItem value="resumes.create" onSelect={onCreate}>
<PlusIcon />
<Trans>Create a new resume</Trans>
</CommandItem>
{isLoading ? (
<CommandLoading>
<Trans>Loading resumes</Trans>
</CommandLoading>
) : (
filteredResumes.map((resume) => (
<CommandItem
key={resume.id}
value={`resume.${resume.id}`}
keywords={[resume.name, resume.slug]}
onSelect={() => onNavigate(`/builder/${resume.id}`)}
>
<ReadCvLogoIcon />
{resume.name}
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
Press <Kbd>Enter</Kbd> to open
</Trans>
</CommandShortcut>
</CommandItem>
))
)}
</BaseCommandGroup>
) : null}
{searchPage === "applications" ? (
<BaseCommandGroup page={commandSearchPage} heading={<Trans>Applications</Trans>}>
<CommandItem value="applications.create" onSelect={onCreateApplication}>
<PlusIcon />
<Trans>New Application</Trans>
</CommandItem>
{isLoadingApplications ? (
<CommandLoading>
<Trans>Loading applications</Trans>
</CommandLoading>
) : (
filteredApplications.map((application) => (
<CommandItem
key={application.id}
value={`application.${application.id}`}
keywords={[application.company, application.role]}
onSelect={() => onOpenApplication(application)}
>
<BriefcaseIcon />
<span className="min-w-0 truncate">{application.company}</span>
<span className="truncate text-muted-foreground text-xs">{application.role}</span>
</CommandItem>
))
)}
</BaseCommandGroup>
) : null}
{searchPage === "threads" ? (
<BaseCommandGroup page={commandSearchPage} heading={<Trans>Threads</Trans>}>
<CommandItem value="threads.create" onSelect={() => onNavigate("/agent/new")}>
<PlusIcon />
<Trans>New Thread</Trans>
</CommandItem>
{isLoadingThreads ? (
<CommandLoading>
<Trans>Loading threads</Trans>
</CommandLoading>
) : (
filteredThreads.map((thread) => {
const title = thread.title === thread.resumeName ? t`New thread` : thread.title;
const resumeName = thread.resumeName ?? "";
const providerLabel = thread.providerLabel ?? "";
return (
<CommandItem
key={thread.id}
value={`thread.${thread.id}`}
keywords={[title, resumeName, providerLabel]}
onSelect={() => onOpenThread(thread)}
>
<ChatCircleDotsIcon />
<span className="min-w-0 truncate">{title}</span>
<span className="truncate text-muted-foreground text-xs">{resumeName}</span>
</CommandItem>
);
})
)}
</BaseCommandGroup>
) : null}
</>
);
}
@@ -15,7 +15,7 @@ import {
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@reactive-resume/ui/components/input-group";
@@ -49,9 +49,11 @@ const searchSchema = z.object({
tags: z.array(z.string()).default([]),
sort: z.enum(["updated", "applied", "company", "role"]).default("updated"),
archived: z.boolean().default(false),
create: z.boolean().default(false),
applicationId: z.string().optional(),
});
type Search = z.output<typeof searchSchema>;
const defaultSearch: Search = { search: "", view: "board", tags: [], sort: "updated", archived: false };
const defaultSearch: Search = { search: "", view: "board", tags: [], sort: "updated", archived: false, create: false };
export const Route = createFileRoute("/dashboard/applications/")({
component: RouteComponent,
@@ -61,7 +63,7 @@ export const Route = createFileRoute("/dashboard/applications/")({
function RouteComponent() {
const { i18n } = useLingui();
const { search, view, tags, sort, archived } = Route.useSearch();
const { search, view, tags, sort, archived, create, applicationId } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const [addOpen, setAddOpen] = useState(false);
@@ -75,9 +77,23 @@ function RouteComponent() {
setEditing(application);
};
useEffect(() => {
if (!create) return;
setAddOpen(true);
void navigate({ replace: true, search: (prev: Search) => ({ ...prev, create: false }) });
}, [create, navigate]);
const { data: applications } = useQuery(applicationsListQueryOptions());
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
useEffect(() => {
if (!applicationId || !applications) return;
setSelected(applications.find((application) => application.id === applicationId) ?? null);
void navigate({ replace: true, search: (prev: Search) => ({ ...prev, applicationId: undefined }) });
}, [applicationId, applications, navigate]);
// Board & table hide archived; tag/search filters + sort are applied client-side.
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();