Cmdk based command bar (#110)

* initial commit

* use colours!

* match by scoring

* scroll job card into view

* introduce @ based 'locks' to restrict search to specific statuses

* clear lock states on close

* split up component

* inline pill

* resuse job row content

* fix intro anim

* larger size, instruction

* refactor existing search feature

* lock colour border

* if active, clear active on escape

* remove query param

* documenration update

* scoring logic

* check exists before scroll

* status dot and checkbox occupy the same space!
This commit is contained in:
Shaheer Sarfaraz 2026-02-09 16:38:36 +00:00 committed by GitHub
parent a24522437c
commit 4cca521cd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1608 additions and 175 deletions

View File

@ -94,6 +94,7 @@ POST /api/jobs/:id/generate-pdf
- `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`.
- The PDF is served at `/pdfs/resume_<jobId>.pdf` and cache-busted with the job?s `updatedAt` timestamp.
- If a job is `skipped` or `applied` and you want to re-open it, you can PATCH its `status` back to `discovered`.
- Job text search is handled through the command bar (`Cmd/Ctrl+K`) and is not persisted as a URL filter.
## External payload and sanitization defaults

View File

@ -89,6 +89,7 @@ orchestrator/
2. **You review in the UI:**
- See jobs at `http://localhost:5173`
- "Ready" tab shows jobs with generated PDFs
- Use command bar search (`Cmd/Ctrl+K`) to quickly find and open jobs
- Click "View Job" to open application
- Download PDF and apply manually
- Click "Mark Applied" → syncs to Notion

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
getMetaKeyLabel,
getMetaShortcutLabel,
isMetaKeyPressed,
} from "./meta-key";
describe("meta-key helper", () => {
it("returns command symbol for apple platforms", () => {
expect(
getMetaKeyLabel({
platform: "MacIntel",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)",
}),
).toBe("⌘");
});
it("returns ctrl label for non-apple platforms", () => {
expect(
getMetaKeyLabel({
platform: "Win32",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
}),
).toBe("Ctrl");
});
it("formats shortcut labels by platform", () => {
expect(getMetaShortcutLabel("k", { platform: "MacIntel" })).toBe("⌘K");
expect(getMetaShortcutLabel("k", { platform: "Linux x86_64" })).toBe(
"Ctrl+K",
);
});
it("detects pressed meta/ctrl modifier", () => {
expect(isMetaKeyPressed({ metaKey: true, ctrlKey: false })).toBe(true);
expect(isMetaKeyPressed({ metaKey: false, ctrlKey: true })).toBe(true);
expect(isMetaKeyPressed({ metaKey: false, ctrlKey: false })).toBe(false);
});
});

View File

@ -0,0 +1,34 @@
interface NavigatorLike {
platform?: string;
userAgent?: string;
}
const isApplePlatform = (value: string) =>
/(mac|iphone|ipad|ipod)/i.test(value);
const getRuntimeNavigator = (): NavigatorLike | null => {
if (typeof navigator === "undefined") return null;
return navigator;
};
export const getMetaKeyLabel = (
nav: NavigatorLike | null = getRuntimeNavigator(),
) => {
const platform = nav?.platform ?? "";
const userAgent = nav?.userAgent ?? "";
return isApplePlatform(`${platform} ${userAgent}`) ? "⌘" : "Ctrl";
};
export const getMetaShortcutLabel = (
key: string,
nav: NavigatorLike | null = getRuntimeNavigator(),
) => {
const normalizedKey = key.trim().toUpperCase();
const meta = getMetaKeyLabel(nav);
return meta === "⌘" ? `${meta}${normalizedKey}` : `${meta}+${normalizedKey}`;
};
export const isMetaKeyPressed = (event: {
metaKey: boolean;
ctrlKey: boolean;
}) => event.metaKey || event.ctrlKey;

View File

@ -164,10 +164,32 @@ vi.mock("./orchestrator/OrchestratorSummary", () => ({
OrchestratorSummary: () => <div data-testid="summary" />,
}));
vi.mock("./orchestrator/JobCommandBar", () => ({
JobCommandBar: ({
onSelectJob,
open,
onOpenChange,
}: {
onSelectJob: (tab: FilterTab, id: string) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) => (
<div>
<div data-testid="command-open">{open ? "open" : "closed"}</div>
<button type="button" onClick={() => onSelectJob("discovered", "job-2")}>
Command Select Job
</button>
<button type="button" onClick={() => onOpenChange?.(false)}>
Close Command Bar
</button>
</div>
),
}));
vi.mock("./orchestrator/OrchestratorFilters", () => ({
OrchestratorFilters: ({
onTabChange,
onSearchQueryChange,
onOpenCommandBar,
onSourceFilterChange,
onSponsorFilterChange,
onSalaryFilterChange,
@ -177,7 +199,7 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
filteredCount,
}: {
onTabChange: (t: FilterTab) => void;
onSearchQueryChange: (q: string) => void;
onOpenCommandBar: () => void;
onSourceFilterChange: (source: string) => void;
onSponsorFilterChange: (value: string) => void;
onSalaryFilterChange: (value: {
@ -196,8 +218,8 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
<button type="button" onClick={() => onTabChange("discovered")}>
To Discovered
</button>
<button type="button" onClick={() => onSearchQueryChange("test search")}>
Set Search
<button type="button" onClick={onOpenCommandBar}>
Open Command Bar
</button>
<button
type="button"
@ -247,6 +269,9 @@ vi.mock("./orchestrator/JobListPanel", () => ({
selectedJobId: string | null;
}) => (
<div>
<div data-job-id="job-1" />
<div data-job-id="job-2" />
<div data-job-id="job-3" />
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
<button
data-testid="toggle-select-all-on"
@ -418,13 +443,45 @@ describe("OrchestratorPage", () => {
});
});
it("syncs search query to URL as a parameter", () => {
it("opens the command bar when the filters search button is clicked", () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByTestId("command-open")).toHaveTextContent("closed");
fireEvent.click(screen.getByText("Open Command Bar"));
expect(screen.getByTestId("command-open")).toHaveTextContent("open");
fireEvent.click(screen.getByText("Close Command Bar"));
expect(screen.getByTestId("command-open")).toHaveTextContent("closed");
});
it("navigates from command search across states and clears active filters", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
const scrollIntoViewMock = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoViewMock,
});
try {
render(
<MemoryRouter
initialEntries={[
"/ready?source=linkedin&sponsor=confirmed&salaryMode=between&salaryMin=60000&salaryMax=90000&q=backend&sort=title-asc",
]}
>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
@ -433,10 +490,48 @@ describe("OrchestratorPage", () => {
</MemoryRouter>,
);
fireEvent.click(screen.getByText("Set Search"));
expect(screen.getByTestId("location").textContent).toContain(
"q=test+search",
fireEvent.click(screen.getByText("Command Select Job"));
await waitFor(() => {
const locationText = screen.getByTestId("location").textContent || "";
expect(locationText).toContain("/discovered/job-2");
expect(locationText).toContain("sort=title-asc");
expect(locationText).not.toContain("source=");
expect(locationText).not.toContain("sponsor=");
expect(locationText).not.toContain("salaryMode=");
expect(locationText).not.toContain("salaryMin=");
expect(locationText).not.toContain("salaryMax=");
expect(locationText).not.toContain("q=");
});
expect(scrollIntoViewMock).toHaveBeenCalled();
} finally {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: originalScrollIntoView,
});
}
});
it("removes legacy q query params on load", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready?q=backend&sort=title-asc"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => {
const locationText = screen.getByTestId("location").textContent || "";
expect(locationText).toContain("sort=title-asc");
expect(locationText).not.toContain("q=");
});
});
it("syncs sorting to URL and removes it when default", () => {

View File

@ -19,6 +19,7 @@ import type { AutomaticRunValues } from "./orchestrator/automatic-run";
import { deriveExtractorLimits } from "./orchestrator/automatic-run";
import type { FilterTab } from "./orchestrator/constants";
import { FloatingBulkActionsBar } from "./orchestrator/FloatingBulkActionsBar";
import { JobCommandBar } from "./orchestrator/JobCommandBar";
import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
import { JobListPanel } from "./orchestrator/JobListPanel";
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
@ -37,13 +38,14 @@ import {
getSourcesWithJobs,
} from "./orchestrator/utils";
const escapeCssAttributeValue = (value: string) =>
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
export const OrchestratorPage: React.FC = () => {
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
const navigate = useNavigate();
const {
searchParams,
searchQuery,
setSearchQuery,
sourceFilter,
setSourceFilter,
sponsorFilter,
@ -89,7 +91,11 @@ export const OrchestratorPage: React.FC = () => {
const [navOpen, setNavOpen] = useState(false);
const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false);
const [runMode, setRunMode] = useState<RunMode>("automatic");
const [isCommandBarOpen, setIsCommandBarOpen] = useState(false);
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
const [pendingCommandScrollJobId, setPendingCommandScrollJobId] = useState<
string | null
>(null);
const [isCancelling, setIsCancelling] = useState(false);
const [isDesktop, setIsDesktop] = useState(() =>
typeof window !== "undefined"
@ -134,7 +140,6 @@ export const OrchestratorPage: React.FC = () => {
sourceFilter,
sponsorFilter,
salaryFilter,
searchQuery,
sort,
);
const counts = useMemo(() => getJobCounts(jobs), [jobs]);
@ -287,6 +292,51 @@ export const OrchestratorPage: React.FC = () => {
}
};
const handleCommandSelectJob = useCallback(
(targetTab: FilterTab, id: string) => {
setPendingCommandScrollJobId(id);
const nextParams = new URLSearchParams(searchParams);
for (const key of [
"source",
"sponsor",
"salaryMode",
"salaryMin",
"salaryMax",
"minSalary",
]) {
nextParams.delete(key);
}
const query = nextParams.toString();
navigate(`/${targetTab}/${id}${query ? `?${query}` : ""}`);
if (!isDesktop) {
setIsDetailDrawerOpen(true);
}
},
[isDesktop, navigate, searchParams],
);
useEffect(() => {
if (!pendingCommandScrollJobId) return;
if (selectedJobId !== pendingCommandScrollJobId) return;
const hasPendingTargetInList = activeJobs.some(
(job) => job.id === pendingCommandScrollJobId,
);
if (!hasPendingTargetInList) return;
if (typeof document === "undefined") return;
const selector = `[data-job-id="${escapeCssAttributeValue(
pendingCommandScrollJobId,
)}"]`;
const target = document.querySelector<HTMLElement>(selector);
if (!target) return;
target.scrollIntoView({
behavior: isDesktop ? "smooth" : "auto",
block: "center",
});
setPendingCommandScrollJobId(null);
}, [activeJobs, isDesktop, pendingCommandScrollJobId, selectedJobId]);
useEffect(() => {
if (activeJobs.length === 0) {
if (selectedJobId) handleSelectJobId(null);
@ -366,12 +416,17 @@ export const OrchestratorPage: React.FC = () => {
{/* Main content: tabs/filters -> list/detail */}
<section className="space-y-4">
<JobCommandBar
jobs={jobs}
onSelectJob={handleCommandSelectJob}
open={isCommandBarOpen}
onOpenChange={setIsCommandBarOpen}
/>
<OrchestratorFilters
activeTab={activeTab}
onTabChange={setActiveTab}
counts={counts}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onOpenCommandBar={() => setIsCommandBarOpen(true)}
sourceFilter={sourceFilter}
onSourceFilterChange={setSourceFilter}
sponsorFilter={sponsorFilter}
@ -395,7 +450,6 @@ export const OrchestratorPage: React.FC = () => {
selectedJobId={selectedJobId}
selectedJobIds={selectedJobIds}
activeTab={activeTab}
searchQuery={searchQuery}
onSelectJob={handleSelectJob}
onToggleSelectJob={toggleSelectJob}
onToggleSelectAll={toggleSelectAll}

View File

@ -0,0 +1,498 @@
import type { Job } from "@shared/types.js";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { JobCommandBar } from "./JobCommandBar";
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
afterAll(() => {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: originalScrollIntoView,
});
});
const createJob = (overrides: Partial<Job>): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme Labs",
employerUrl: null,
jobUrl: "https://example.com/job-1",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "California",
degreeRequired: null,
starting: null,
jobDescription: null,
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
...overrides,
});
describe("JobCommandBar", () => {
const openWithKeyboard = () => {
fireEvent.keyDown(window, { key: "k", ctrlKey: true });
};
it("opens the command dialog with keyboard shortcut", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
expect(
screen.getByPlaceholderText(
"Search jobs by job title or company name...",
),
).toBeInTheDocument();
});
it("creates discovered lock from @disc + Tab", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1", status: "discovered" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@disc" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByText("@discovered")).toBeInTheDocument();
});
it("creates lock from @ready + Enter", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1", status: "ready" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("@ready")).toBeInTheDocument();
});
it("adds lock-colored border and shadow to dialog when a lock is active", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1", status: "discovered" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const dialog = screen.getByRole("dialog");
expect(dialog.className).not.toContain("border-sky-500/50");
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@disc" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(dialog).toHaveClass("border-sky-500/50");
expect(dialog.className).toContain(
"shadow-[0_0_0_1px_rgba(14,165,233,0.2),0_0_36px_-12px_rgba(14,165,233,0.55)]",
);
});
it("shows selectable filter suggestion for @ tokens", () => {
render(
<JobCommandBar
jobs={[
createJob({
id: "ready-job",
title: "Ready Engineer",
status: "ready",
}),
]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@ready" } });
expect(screen.getByText("Lock to @ready")).toBeInTheDocument();
expect(screen.queryByText("No jobs found.")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Lock to @ready"));
expect(screen.getByText("@ready")).toBeInTheDocument();
expect(screen.getByText("Ready Engineer")).toBeInTheDocument();
});
it("shows all lock suggestions for bare @", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "ready-job", status: "ready" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@" } });
expect(screen.getByText("Lock to @ready")).toBeInTheDocument();
expect(screen.getByText("Lock to @discovered")).toBeInTheDocument();
expect(screen.getByText("Lock to @applied")).toBeInTheDocument();
expect(screen.getByText("Lock to @skipped")).toBeInTheDocument();
expect(screen.getByText("Lock to @expired")).toBeInTheDocument();
});
it("searches by company name and routes to the matched state", () => {
const onSelectJob = vi.fn();
const jobs: Job[] = [
createJob({
id: "ready-job",
title: "Backend Engineer",
status: "ready",
}),
createJob({
id: "applied-job",
title: "Platform Engineer",
employer: "Globex",
status: "applied",
}),
];
render(<JobCommandBar jobs={jobs} onSelectJob={onSelectJob} />);
openWithKeyboard();
fireEvent.change(
screen.getByPlaceholderText(
"Search jobs by job title or company name...",
),
{
target: { value: "Globex" },
},
);
fireEvent.click(screen.getByText("Platform Engineer"));
expect(onSelectJob).toHaveBeenCalledWith("applied", "applied-job");
});
it("returns only locked status results", () => {
const jobs: Job[] = [
createJob({
id: "disc-1",
title: "Frontend Engineer",
status: "discovered",
}),
createJob({
id: "applied-1",
title: "Frontend Engineer",
status: "applied",
}),
];
render(<JobCommandBar jobs={jobs} onSelectJob={vi.fn()} />);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@disc" } });
fireEvent.keyDown(input, { key: "Tab" });
fireEvent.change(input, { target: { value: "Frontend" } });
expect(screen.getByText("Frontend Engineer")).toBeInTheDocument();
expect(screen.queryByText("@applied")).not.toBeInTheDocument();
expect(screen.queryByText("Applied")).not.toBeInTheDocument();
});
it("ranks closest match first within a lock", () => {
const jobs: Job[] = [
createJob({
id: "ready-job",
title: "Junior Software Engineer (Data Products)",
employer: "Yapily",
status: "ready",
}),
createJob({
id: "discovered-job",
title: "Junior Web Developer",
employer: "Joinrs",
status: "discovered",
}),
createJob({
id: "discovered-job-2",
title: "Junior Software Engineer",
employer: "Nestle",
status: "discovered",
}),
];
render(<JobCommandBar jobs={jobs} onSelectJob={vi.fn()} />);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@disc" } });
fireEvent.keyDown(input, { key: "Tab" });
fireEvent.change(input, {
target: { value: "joinrs" },
});
const options = screen.getAllByRole("option");
expect(options[0]).toHaveTextContent("Joinrs");
expect(options[0]).toHaveTextContent("Junior Web Developer");
});
it("replaces an existing lock when new @ token is tab-completed", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1", status: "ready" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByText("@ready")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "@app" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByText("@applied")).toBeInTheDocument();
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
});
it("does not show an x remove button on the lock chip", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1", status: "ready" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByText("@ready")).toBeInTheDocument();
expect(
screen.queryByLabelText("Remove ready filter"),
).not.toBeInTheDocument();
});
it("removes lock with Backspace when query is empty", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1", status: "ready" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
fireEvent.keyDown(input, { key: "Backspace" });
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
});
it("clears lock on Escape and keeps dialog open", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1", status: "ready" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByText("@ready")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Escape" });
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
expect(
screen.getByPlaceholderText(
"Search jobs by job title or company name...",
),
).toBeInTheDocument();
});
it("clears active lock when the dialog closes", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1", status: "ready" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@ready" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByText("@ready")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
openWithKeyboard();
expect(screen.queryByText("@ready")).not.toBeInTheDocument();
});
it("treats @all as invalid and does not lock", () => {
render(
<JobCommandBar
jobs={[createJob({ id: "job-1", status: "ready" })]}
onSelectJob={vi.fn()}
/>,
);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
fireEvent.change(input, { target: { value: "@all" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(
screen.queryByText(/^@(ready|discovered|applied|skipped|expired)$/),
).not.toBeInTheDocument();
expect((input as HTMLInputElement).value).toBe("@all");
});
it("excludes processing jobs from every lock scope", () => {
const jobs: Job[] = [
createJob({
id: "processing-job",
title: "Processing-only keyword",
employer: "Queue Corp",
status: "processing",
}),
createJob({
id: "ready-job",
title: "Ready Engineer",
status: "ready",
}),
createJob({
id: "disc-job",
title: "Discovered Engineer",
status: "discovered",
}),
createJob({
id: "applied-job",
title: "Applied Engineer",
status: "applied",
}),
createJob({
id: "skipped-job",
title: "Skipped Engineer",
status: "skipped",
}),
createJob({
id: "expired-job",
title: "Expired Engineer",
status: "expired",
}),
];
render(<JobCommandBar jobs={jobs} onSelectJob={vi.fn()} />);
openWithKeyboard();
const input = screen.getByPlaceholderText(
"Search jobs by job title or company name...",
);
const lockTokens = ["@ready", "@disc", "@applied", "@skip", "@exp"];
for (const token of lockTokens) {
fireEvent.change(input, { target: { value: token } });
fireEvent.keyDown(input, { key: "Tab" });
fireEvent.change(input, { target: { value: "Processing-only keyword" } });
expect(
screen.queryByText("Processing-only keyword"),
).not.toBeInTheDocument();
}
});
});

View File

@ -0,0 +1,223 @@
import { isMetaKeyPressed } from "@client/lib/meta-key";
import type { Job } from "@shared/types.js";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import type { FilterTab } from "./constants";
import {
extractLeadingAtToken,
getFilterTab,
getLockMatchesFromAliasPrefix,
groupJobsForCommandBar,
jobMatchesLock,
orderCommandGroups,
resolveLockFromAliasPrefix,
type StatusLock,
stripLeadingAtToken,
} from "./JobCommandBar.utils";
import { JobCommandBarLockBadge } from "./JobCommandBarLockBadge";
import { JobCommandBarLockSuggestions } from "./JobCommandBarLockSuggestions";
import { JobRowContent } from "./JobRowContent";
interface JobCommandBarProps {
jobs: Job[];
onSelectJob: (tab: FilterTab, jobId: string) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export const JobCommandBar: React.FC<JobCommandBarProps> = ({
jobs,
onSelectJob,
open,
onOpenChange,
}) => {
const lockDialogAccentClass: Record<StatusLock, string> = {
ready:
"border-emerald-500/50 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_0_36px_-12px_rgba(16,185,129,0.55)]",
discovered:
"border-sky-500/50 shadow-[0_0_0_1px_rgba(14,165,233,0.2),0_0_36px_-12px_rgba(14,165,233,0.55)]",
applied:
"border-emerald-500/50 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_0_36px_-12px_rgba(16,185,129,0.55)]",
skipped:
"border-rose-500/50 shadow-[0_0_0_1px_rgba(244,63,94,0.2),0_0_36px_-12px_rgba(244,63,94,0.55)]",
expired:
"border-zinc-400/40 shadow-[0_0_0_1px_rgba(161,161,170,0.2),0_0_32px_-12px_rgba(161,161,170,0.45)]",
};
const [internalOpen, setInternalOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeLock, setActiveLock] = useState<StatusLock | null>(null);
const isOpenControlled = typeof open === "boolean";
const isOpen = isOpenControlled ? open : internalOpen;
const setDialogOpen = useCallback(
(nextOpen: boolean) => {
if (!isOpenControlled) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
},
[isOpenControlled, onOpenChange],
);
const closeDialog = useCallback(() => {
setDialogOpen(false);
setActiveLock(null);
}, [setDialogOpen]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== "k") return;
if (!isMetaKeyPressed(event)) return;
event.preventDefault();
if (isOpen) {
closeDialog();
return;
}
setDialogOpen(true);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [closeDialog, isOpen, setDialogOpen]);
const normalizedQuery = query.trim().toLowerCase();
const scopedJobs = useMemo(() => {
if (!activeLock) return jobs;
return jobs.filter((job) => jobMatchesLock(job, activeLock));
}, [activeLock, jobs]);
const groupedJobs = useMemo(
() => groupJobsForCommandBar(scopedJobs, normalizedQuery),
[normalizedQuery, scopedJobs],
);
const orderedGroups = useMemo(
() => orderCommandGroups(groupedJobs, normalizedQuery),
[groupedJobs, normalizedQuery],
);
const applyLock = (lock: StatusLock) => {
setActiveLock(lock);
setQuery((current) => stripLeadingAtToken(current));
};
useEffect(() => {
if (isOpen) return;
setActiveLock(null);
}, [isOpen]);
const lockSuggestions = useMemo(() => {
if (activeLock) return [];
const token = extractLeadingAtToken(query);
if (token === null) return [];
return getLockMatchesFromAliasPrefix(token);
}, [activeLock, query]);
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (
(event.key === "Tab" || event.key === "Enter") &&
!event.shiftKey &&
!event.altKey
) {
const token = extractLeadingAtToken(query);
if (!token) return;
const nextLock = resolveLockFromAliasPrefix(token);
if (!nextLock) return;
event.preventDefault();
applyLock(nextLock);
return;
}
if (event.key === "Backspace" && query.length === 0 && activeLock) {
event.preventDefault();
setActiveLock(null);
}
};
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setDialogOpen(true);
return;
}
closeDialog();
};
return (
<CommandDialog
open={isOpen}
onOpenChange={handleOpenChange}
onEscapeKeyDown={(event) => {
if (!activeLock) return;
event.preventDefault();
setActiveLock(null);
}}
contentClassName={`max-w-4xl transition-[border-color,box-shadow] duration-200 ${activeLock ? lockDialogAccentClass[activeLock] : ""}`}
>
<DialogTitle className="sr-only">Job Search</DialogTitle>
<DialogDescription className="sr-only">
Search jobs across all states by job title or company name.
</DialogDescription>
<CommandInput
placeholder="Search jobs by job title or company name..."
value={query}
onValueChange={setQuery}
onKeyDown={handleInputKeyDown}
prefix={
activeLock ? (
<JobCommandBarLockBadge activeLock={activeLock} />
) : undefined
}
/>
<div className="px-3 py-1 text-[11px] text-muted-foreground border-b">
Use <span className="font-mono">@</span> + status + Tab/Enter to lock a
status. Backspace on empty search clears the lock.
</div>
<CommandList className="max-h-[65vh]">
<CommandEmpty>No jobs found.</CommandEmpty>
{!activeLock && (
<JobCommandBarLockSuggestions
suggestions={lockSuggestions}
onSelect={applyLock}
/>
)}
{orderedGroups.map((group, index) => {
const items = groupedJobs[group.id];
if (items.length === 0) return null;
return (
<div key={group.id}>
{index > 0 && <CommandSeparator />}
<CommandGroup heading={group.heading}>
{items.map((job) => {
return (
<CommandItem
key={job.id}
value={`${job.id} ${job.title} ${job.employer}`}
keywords={[job.title, job.employer]}
onSelect={() => {
closeDialog();
onSelectJob(getFilterTab(job.status), job.id);
}}
>
<JobRowContent job={job} />
</CommandItem>
);
})}
</CommandGroup>
</div>
);
})}
</CommandList>
</CommandDialog>
);
};

View File

@ -0,0 +1,116 @@
import type { Job } from "@shared/types.js";
import { describe, expect, it } from "vitest";
import {
computeJobMatchScore,
groupJobsForCommandBar,
} from "./JobCommandBar.utils";
const createJob = (overrides: Partial<Job>): Job => ({
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme Labs",
employerUrl: null,
jobUrl: "https://example.com/job-1",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "California",
degreeRequired: null,
starting: null,
jobDescription: null,
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
...overrides,
});
describe("JobCommandBar score helpers", () => {
it("returns zero when no title, employer, or location matches", () => {
const score = computeJobMatchScore(
createJob({
title: "Backend Engineer",
employer: "Acme",
location: "London",
}),
"kubernetes",
);
expect(score).toBe(0);
});
it("ranks exact and fuzzy matches above non-matches for a query", () => {
const grouped = groupJobsForCommandBar(
[
createJob({
id: "no-match",
title: "Visual Designer",
employer: "Studio Co",
discoveredAt: "2025-02-01T00:00:00Z",
}),
createJob({
id: "fuzzy",
title: "Backender Engineer",
employer: "Platform Co",
discoveredAt: "2025-01-02T00:00:00Z",
}),
createJob({
id: "exact",
title: "Backend",
employer: "Infra Co",
discoveredAt: "2025-01-01T00:00:00Z",
}),
],
"backend",
);
expect(grouped.ready.map((job) => job.id)).toEqual([
"exact",
"fuzzy",
"no-match",
]);
});
});

View File

@ -0,0 +1,206 @@
import type { Job, JobStatus } from "@shared/types.js";
import type { FilterTab } from "./constants";
export type CommandGroupId = "ready" | "discovered" | "applied" | "other";
export type StatusLock =
| "ready"
| "discovered"
| "applied"
| "skipped"
| "expired";
export const commandGroupMeta: Array<{ id: CommandGroupId; heading: string }> =
[
{ id: "ready", heading: "Ready" },
{ id: "discovered", heading: "Discovered" },
{ id: "applied", heading: "Applied" },
{ id: "other", heading: "Other" },
];
const lockAliases: Record<StatusLock, string[]> = {
ready: ["ready", "rdy"],
discovered: ["discovered", "discover", "disc"],
applied: ["applied", "apply", "app"],
skipped: ["skipped", "skip", "skp"],
expired: ["expired", "expire", "exp"],
};
export const lockLabel: Record<StatusLock, string> = {
ready: "ready",
discovered: "discovered",
applied: "applied",
skipped: "skipped",
expired: "expired",
};
const tokenRegex = /^\s*@([a-z-]*)/i;
const parseTime = (value: string | null) => {
if (!value) return Number.NaN;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
};
const computeFieldMatchScore = (fieldRaw: string, needleRaw: string) => {
const field = fieldRaw.trim().toLowerCase();
const needle = needleRaw.trim().toLowerCase();
if (!field || !needle) return 0;
if (field === needle) return 1000;
const words = field.split(/\s+/).filter(Boolean);
if (words.includes(needle)) return 920;
if (field.startsWith(needle)) return 880;
if (words.some((word) => word.startsWith(needle))) return 820;
if (field.includes(needle)) return 760;
const compactField = field.replace(/\s+/g, "");
if (compactField.includes(needle)) return 700;
// Light typo-tolerance via ordered-character subsequence matching.
let matchIndex = 0;
for (const character of compactField) {
if (character === needle[matchIndex]) {
matchIndex += 1;
if (matchIndex === needle.length) break;
}
}
if (matchIndex === needle.length) {
const density = needle.length / compactField.length;
return Math.round(500 + density * 100);
}
return 0;
};
export const getCommandGroup = (status: JobStatus): CommandGroupId => {
if (status === "ready") return "ready";
if (status === "discovered" || status === "processing") return "discovered";
if (status === "applied") return "applied";
return "other";
};
export const getFilterTab = (status: JobStatus): FilterTab => {
if (status === "ready") return "ready";
if (status === "discovered" || status === "processing") return "discovered";
if (status === "applied") return "applied";
return "all";
};
export const extractLeadingAtToken = (input: string) => {
const match = tokenRegex.exec(input);
if (!match) return null;
return match[1].toLowerCase();
};
export const stripLeadingAtToken = (input: string) =>
input.replace(tokenRegex, "").trimStart();
export const getLockMatchesFromAliasPrefix = (
rawToken: string,
): StatusLock[] => {
const token = rawToken.trim().toLowerCase();
if (!token) return Object.keys(lockAliases) as StatusLock[];
const matches: StatusLock[] = [];
for (const [status, aliases] of Object.entries(lockAliases) as Array<
[StatusLock, string[]]
>) {
if (aliases.some((alias) => alias.startsWith(token))) {
matches.push(status);
}
}
return matches;
};
export const resolveLockFromAliasPrefix = (
rawToken: string,
): StatusLock | null => {
const matches = getLockMatchesFromAliasPrefix(rawToken);
if (matches.length !== 1) return null;
return matches[0];
};
export const jobMatchesLock = (job: Job, lock: StatusLock) => {
if (lock === "ready") return job.status === "ready";
if (lock === "discovered") return job.status === "discovered";
if (lock === "applied") return job.status === "applied";
if (lock === "skipped") return job.status === "skipped";
if (lock === "expired") return job.status === "expired";
return false;
};
export const computeJobMatchScore = (job: Job, normalizedQuery: string) => {
if (!normalizedQuery) return 0;
const titleScore = computeFieldMatchScore(job.title, normalizedQuery);
const employerScore = computeFieldMatchScore(job.employer, normalizedQuery);
const locationScore = computeFieldMatchScore(
job.location ?? "",
normalizedQuery,
);
// Prefer title/company matches over location when scores tie.
// Only apply bias when a field actually matched.
const titleRankedScore = titleScore > 0 ? titleScore + 8 : 0;
const employerRankedScore = employerScore > 0 ? employerScore + 12 : 0;
return Math.max(titleRankedScore, employerRankedScore, locationScore);
};
export const groupJobsForCommandBar = (
scopedJobs: Job[],
normalizedQuery: string,
): Record<CommandGroupId, Job[]> => {
const groups: Record<CommandGroupId, Job[]> = {
ready: [],
discovered: [],
applied: [],
other: [],
};
const sorted = [...scopedJobs].sort((a, b) => {
if (normalizedQuery) {
const firstScore = computeJobMatchScore(a, normalizedQuery);
const secondScore = computeJobMatchScore(b, normalizedQuery);
if (firstScore !== secondScore) return secondScore - firstScore;
}
const first = parseTime(a.discoveredAt);
const second = parseTime(b.discoveredAt);
if (!Number.isNaN(first) && !Number.isNaN(second)) {
return second - first;
}
if (!Number.isNaN(first)) return -1;
if (!Number.isNaN(second)) return 1;
return b.id.localeCompare(a.id);
});
for (const job of sorted) {
groups[getCommandGroup(job.status)].push(job);
}
return groups;
};
export const orderCommandGroups = (
groupedJobs: Record<CommandGroupId, Job[]>,
normalizedQuery: string,
) => {
if (!normalizedQuery) return commandGroupMeta;
const withScores = commandGroupMeta.map((group) => {
const maxScore = groupedJobs[group.id].reduce(
(currentMax, job) =>
Math.max(currentMax, computeJobMatchScore(job, normalizedQuery)),
0,
);
return {
...group,
maxScore,
};
});
return withScores.sort((a, b) => {
if (a.maxScore !== b.maxScore) return b.maxScore - a.maxScore;
return (
commandGroupMeta.findIndex((group) => group.id === a.id) -
commandGroupMeta.findIndex((group) => group.id === b.id)
);
});
};

View File

@ -0,0 +1,12 @@
import { lockLabel, type StatusLock } from "./JobCommandBar.utils";
import { JobStatusBadge } from "./JobStatusBadge";
interface JobCommandBarLockBadgeProps {
activeLock: StatusLock;
}
export const JobCommandBarLockBadge = ({
activeLock,
}: JobCommandBarLockBadgeProps) => (
<JobStatusBadge status={activeLock} label={`@${lockLabel[activeLock]}`} />
);

View File

@ -0,0 +1,38 @@
import { CommandGroup, CommandItem } from "@/components/ui/command";
import { defaultStatusToken, statusTokens } from "./constants";
import { lockLabel, type StatusLock } from "./JobCommandBar.utils";
interface JobCommandBarLockSuggestionsProps {
suggestions: StatusLock[];
onSelect: (lock: StatusLock) => void;
}
export const JobCommandBarLockSuggestions = ({
suggestions,
onSelect,
}: JobCommandBarLockSuggestionsProps) => {
if (suggestions.length === 0) return null;
return (
<CommandGroup heading="Filters">
{suggestions.map((lock) => {
const token = statusTokens[lock] ?? defaultStatusToken;
return (
<CommandItem
key={lock}
value={`@${lockLabel[lock]} filter`}
keywords={[`@${lockLabel[lock]}`, lockLabel[lock]]}
onSelect={() => onSelect(lock)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className={`h-1.5 w-1.5 rounded-full ${token.dot}`} />
<span className="truncate text-sm font-medium">
Lock to @{lockLabel[lock]}
</span>
</div>
</CommandItem>
);
})}
</CommandGroup>
);
};

View File

@ -76,7 +76,6 @@ describe("JobListPanel", () => {
selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready"
searchQuery=""
onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
@ -95,7 +94,6 @@ describe("JobListPanel", () => {
selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready"
searchQuery=""
onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
@ -108,25 +106,6 @@ describe("JobListPanel", () => {
).toBeInTheDocument();
});
it("shows the query-specific empty state when searching", () => {
render(
<JobListPanel
isLoading={false}
jobs={[]}
activeJobs={[]}
selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready"
searchQuery="iOS"
onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
/>,
);
expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
});
it("renders jobs and notifies when a job is selected", () => {
const onSelectJob = vi.fn();
const onToggleSelectJob = vi.fn();
@ -148,7 +127,6 @@ describe("JobListPanel", () => {
selectedJobId="job-1"
selectedJobIds={new Set()}
activeTab="ready"
searchQuery=""
onSelectJob={onSelectJob}
onToggleSelectJob={onToggleSelectJob}
onToggleSelectAll={onToggleSelectAll}
@ -179,7 +157,6 @@ describe("JobListPanel", () => {
selectedJobId="job-1"
selectedJobIds={new Set(["job-1"])}
activeTab="ready"
searchQuery=""
onSelectJob={vi.fn()}
onToggleSelectJob={onToggleSelectJob}
onToggleSelectAll={onToggleSelectAll}
@ -192,4 +169,61 @@ describe("JobListPanel", () => {
fireEvent.click(screen.getByLabelText("Select all filtered jobs"));
expect(onToggleSelectAll).toHaveBeenCalledWith(true);
});
it("shows checkbox only for selected or checked rows", () => {
const jobs = [createJob({ id: "job-1", title: "Backend Engineer" })];
const { rerender } = render(
<JobListPanel
isLoading={false}
jobs={jobs}
activeJobs={jobs}
selectedJobId={null}
selectedJobIds={new Set()}
activeTab="ready"
onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
/>,
);
expect(screen.getByLabelText("Select Backend Engineer")).toHaveClass(
"opacity-0",
);
rerender(
<JobListPanel
isLoading={false}
jobs={jobs}
activeJobs={jobs}
selectedJobId="job-1"
selectedJobIds={new Set()}
activeTab="ready"
onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
/>,
);
expect(screen.getByLabelText("Select Backend Engineer")).toHaveClass(
"opacity-100",
);
rerender(
<JobListPanel
isLoading={false}
jobs={jobs}
activeJobs={jobs}
selectedJobId={null}
selectedJobIds={new Set(["job-1"])}
activeTab="ready"
onSelectJob={vi.fn()}
onToggleSelectJob={vi.fn()}
onToggleSelectAll={vi.fn()}
/>,
);
expect(screen.getByLabelText("Select Backend Engineer")).toHaveClass(
"opacity-100",
);
});
});

View File

@ -5,6 +5,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import type { FilterTab } from "./constants";
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
import { JobRowContent } from "./JobRowContent";
interface JobListPanelProps {
isLoading: boolean;
@ -13,7 +14,6 @@ interface JobListPanelProps {
selectedJobId: string | null;
selectedJobIds: Set<string>;
activeTab: FilterTab;
searchQuery: string;
onSelectJob: (jobId: string) => void;
onToggleSelectJob: (jobId: string) => void;
onToggleSelectAll: (checked: boolean) => void;
@ -26,7 +26,6 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
selectedJobId,
selectedJobIds,
activeTab,
searchQuery,
onSelectJob,
onToggleSelectJob,
onToggleSelectAll,
@ -41,9 +40,7 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
<div className="text-base font-semibold">No jobs found</div>
<p className="max-w-md text-sm text-muted-foreground">
{searchQuery.trim()
? `No jobs match "${searchQuery.trim()}".`
: emptyStateCopy[activeTab]}
{emptyStateCopy[activeTab]}
</p>
</div>
) : (
@ -76,95 +73,60 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
{activeJobs.map((job) => {
const isSelected = job.id === selectedJobId;
const isChecked = selectedJobIds.has(job.id);
const hasScore = job.suitabilityScore != null;
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
return (
<div
key={job.id}
data-job-id={job.id}
className={cn(
"group flex items-center gap-3 px-4 py-3 transition-colors cursor-pointer border-l-2 border-b",
isChecked
? "!border-l !border-l-primary !bg-muted/40"
: "border-l border-l-border/40",
isSelected
? "bg-primary/5"
? "bg-primary/15"
: "border-b-border/40 hover:bg-muted/20",
isChecked && isSelected && "outline-2 outline-primary/30",
)}
>
<div className="relative h-4 w-4 shrink-0">
<span
className={cn(
"absolute inset-0 m-auto h-2 w-2 rounded-full transition-opacity duration-150 ease-out",
statusToken.dot,
isChecked || isSelected
? "opacity-0"
: "opacity-100 group-hover:opacity-0",
)}
title={statusToken.label}
/>
<Checkbox
checked={isChecked}
onCheckedChange={() => onToggleSelectJob(job.id)}
onClick={(event) => event.stopPropagation()}
aria-label={`Select ${job.title}`}
className={cn(
"border-border/80 cursor-pointer text-muted-foreground/70 transition-opacity",
"absolute inset-0 m-0 border-border/80 cursor-pointer text-muted-foreground/70 transition-opacity duration-150 ease-out",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/20 data-[state=checked]:text-primary",
"data-[state=checked]:shadow-[0_0_0_1px_hsl(var(--primary)/0.35)]",
isChecked || isSelected
? "opacity-100"
: "opacity-100 pointer-events-auto sm:opacity-0 sm:pointer-events-none sm:group-hover:pointer-events-auto sm:group-hover:opacity-100",
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
)}
/>
{/* Single status indicator: subtle dot */}
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
statusToken.dot,
!isSelected && "opacity-70",
)}
title={statusToken.label}
/>
</div>
<button
type="button"
onClick={() => onSelectJob(job.id)}
data-testid={`select-${job.id}`}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
className="flex min-w-0 flex-1 cursor-pointer text-left"
aria-pressed={isSelected}
>
{/* Primary content: title strongest, company secondary */}
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm leading-tight",
isSelected ? "font-semibold" : "font-medium",
)}
>
{job.title}
</div>
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.employer}
{job.location && (
<span className="before:content-['_in_']">
{job.location}
</span>
)}
</div>
{job.salary?.trim() && (
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.salary}
</div>
)}
</div>
{/* Single triage cue: score only (status shown via dot) */}
{hasScore && (
<div className="shrink-0 text-right">
<span
className={cn(
"text-xs tabular-nums",
(job.suitabilityScore ?? 0) >= 70
? "text-emerald-400/90"
: (job.suitabilityScore ?? 0) >= 50
? "text-foreground/60"
: "text-muted-foreground/60",
)}
>
{job.suitabilityScore}
</span>
</div>
)}
<JobRowContent
job={job}
isSelected={isSelected}
showStatusDot={false}
/>
</button>
</div>
);

View File

@ -0,0 +1,76 @@
import type { Job } from "@shared/types.js";
import { cn } from "@/lib/utils";
import { defaultStatusToken, statusTokens } from "./constants";
interface JobRowContentProps {
job: Job;
isSelected?: boolean;
showStatusDot?: boolean;
statusDotClassName?: string;
className?: string;
}
export const JobRowContent = ({
job,
isSelected = false,
showStatusDot = true,
statusDotClassName,
className,
}: JobRowContentProps) => {
const hasScore = job.suitabilityScore != null;
const statusToken = statusTokens[job.status] ?? defaultStatusToken;
return (
<div className={cn("flex min-w-0 flex-1 items-center gap-3", className)}>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
statusToken.dot,
!isSelected && "opacity-70",
statusDotClassName,
!showStatusDot && "hidden",
)}
title={statusToken.label}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm leading-tight",
isSelected ? "font-semibold" : "font-medium",
)}
>
{job.title}
</div>
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.employer}
{job.location && (
<span className="before:content-['_in_']">{job.location}</span>
)}
</div>
{job.salary?.trim() && (
<div className="truncate text-xs text-muted-foreground mt-0.5">
{job.salary}
</div>
)}
</div>
{hasScore && (
<div className="shrink-0 text-right">
<span
className={cn(
"text-xs tabular-nums",
(job.suitabilityScore ?? 0) >= 70
? "text-emerald-400/90"
: (job.suitabilityScore ?? 0) >= 50
? "text-foreground/60"
: "text-muted-foreground/60",
)}
>
{job.suitabilityScore}
</span>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,29 @@
import type { JobStatus } from "@shared/types.js";
import { cn } from "@/lib/utils";
import { defaultStatusToken, statusTokens } from "./constants";
interface JobStatusBadgeProps {
status: JobStatus;
label?: string;
className?: string;
}
export const JobStatusBadge = ({
status,
label,
className,
}: JobStatusBadgeProps) => {
const statusToken = statusTokens[status] ?? defaultStatusToken;
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold tracking-wide",
statusToken.badge,
className,
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", statusToken.dot)} />
{label ?? statusToken.label}
</span>
);
};

View File

@ -33,8 +33,7 @@ const renderFilters = (
applied: 3,
all: 6,
},
searchQuery: "",
onSearchQueryChange: vi.fn(),
onOpenCommandBar: vi.fn(),
sourceFilter: "all" as const,
onSourceFilterChange: vi.fn(),
sponsorFilter: "all" as SponsorFilter,
@ -60,16 +59,14 @@ const renderFilters = (
};
describe("OrchestratorFilters", () => {
it("notifies when tabs and search are updated", () => {
it("notifies when tabs and command search shortcut are used", () => {
const { props } = renderFilters();
fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
expect(props.onTabChange).toHaveBeenCalledWith("applied");
fireEvent.change(screen.getByPlaceholderText("Search..."), {
target: { value: "Design" },
});
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
fireEvent.click(screen.getByRole("button", { name: /search jobs/i }));
expect(props.onOpenCommandBar).toHaveBeenCalled();
});
it("updates source, sponsor, salary range, and sort from the drawer", async () => {

View File

@ -1,3 +1,4 @@
import { getMetaShortcutLabel } from "@client/lib/meta-key";
import type { JobSource } from "@shared/types.js";
import { Filter, Search } from "lucide-react";
import type React from "react";
@ -37,8 +38,7 @@ interface OrchestratorFiltersProps {
activeTab: FilterTab;
onTabChange: (value: FilterTab) => void;
counts: Record<FilterTab, number>;
searchQuery: string;
onSearchQueryChange: (value: string) => void;
onOpenCommandBar: () => void;
sourceFilter: JobSource | "all";
onSourceFilterChange: (value: JobSource | "all") => void;
sponsorFilter: SponsorFilter;
@ -113,8 +113,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
activeTab,
onTabChange,
counts,
searchQuery,
onSearchQueryChange,
onOpenCommandBar,
sourceFilter,
onSourceFilterChange,
sponsorFilter,
@ -146,6 +145,7 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
salaryFilter.mode === "at_least" || salaryFilter.mode === "between";
const showSalaryMax =
salaryFilter.mode === "at_most" || salaryFilter.mode === "between";
const commandShortcutLabel = getMetaShortcutLabel("K");
return (
<Tabs
@ -171,15 +171,20 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
</TabsList>
<div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2">
<div className="relative w-full flex-1 min-w-[180px] lg:max-w-[240px] lg:flex-none">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder="Search..."
className="h-8 pl-8 text-sm"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onOpenCommandBar}
aria-label="Search jobs"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
>
<Search className="h-3.5 w-3.5" />
Search
<span className="rounded border border-border/70 px-1 py-0.5 font-mono text-xs leading-none text-muted-foreground">
{commandShortcutLabel}
</span>
</Button>
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<SheetTrigger asChild>

View File

@ -81,7 +81,6 @@ describe("useFilteredJobs", () => {
"all",
"confirmed",
{ mode: "at_least", min: null, max: null },
"",
{
key: "score",
direction: "desc",
@ -107,7 +106,6 @@ describe("useFilteredJobs", () => {
"all",
"all",
{ mode: "between", min: 60000, max: 80000 },
"",
{
key: "score",
direction: "desc",
@ -136,7 +134,6 @@ describe("useFilteredJobs", () => {
"all",
"all",
{ mode: "at_least", min: null, max: null },
"",
{
key: "salary",
direction: "desc",

View File

@ -6,7 +6,7 @@ import type {
SalaryFilter,
SponsorFilter,
} from "./constants";
import { compareJobs, jobMatchesQuery, parseSalaryBounds } from "./utils";
import { compareJobs, parseSalaryBounds } from "./utils";
const getSponsorCategory = (score: number | null): SponsorFilter => {
if (score == null) return "unknown";
@ -21,7 +21,6 @@ export const useFilteredJobs = (
sourceFilter: JobSource | "all",
sponsorFilter: SponsorFilter,
salaryFilter: SalaryFilter,
searchQuery: string,
sort: JobSort,
) =>
useMemo(() => {
@ -85,17 +84,5 @@ export const useFilteredJobs = (
});
}
if (searchQuery.trim()) {
filtered = filtered.filter((job) => jobMatchesQuery(job, searchQuery));
}
return [...filtered].sort((a, b) => compareJobs(a, b, sort));
}, [
jobs,
activeTab,
sourceFilter,
sponsorFilter,
salaryFilter,
searchQuery,
sort,
]);
}, [jobs, activeTab, sourceFilter, sponsorFilter, salaryFilter, sort]);

View File

@ -1,5 +1,5 @@
import type { JobSource } from "@shared/types.js";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import type {
JobSort,
@ -33,20 +33,16 @@ const allowedSortDirections: JobSort["direction"][] = ["asc", "desc"];
export const useOrchestratorFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
const searchQuery = searchParams.get("q") || "";
const setSearchQuery = useCallback(
(query: string) => {
useEffect(() => {
if (!searchParams.has("q")) return;
setSearchParams(
(prev) => {
if (query) prev.set("q", query);
else prev.delete("q");
prev.delete("q");
return prev;
},
{ replace: true },
);
},
[setSearchParams],
);
}, [searchParams, setSearchParams]);
const sourceFilter =
(searchParams.get("source") as JobSource | "all") || "all";
@ -181,8 +177,6 @@ export const useOrchestratorFilters = () => {
return {
searchParams,
searchQuery,
setSearchQuery,
sourceFilter,
setSourceFilter,
sponsorFilter,

View File

@ -20,11 +20,31 @@ const Command = React.forwardRef<
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
interface CommandDialogProps extends DialogProps {
contentClassName?: string;
commandClassName?: string;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
}
const CommandDialog = ({
children,
contentClassName,
commandClassName,
onEscapeKeyDown,
...props
}: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<DialogContent
className={cn("overflow-hidden p-0", contentClassName)}
onEscapeKeyDown={onEscapeKeyDown}
>
<Command
className={cn(
"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5",
commandClassName,
)}
>
{children}
</Command>
</DialogContent>
@ -32,12 +52,27 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
);
};
interface CommandInputProps
extends Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
"prefix"
> {
prefix?: React.ReactNode;
wrapperClassName?: string;
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
CommandInputProps
>(({ className, prefix, wrapperClassName, ...props }, ref) => (
<div
className={cn("flex items-center border-b px-3", wrapperClassName)}
cmdk-input-wrapper=""
>
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
{prefix ? (
<div className="mr-2 flex shrink-0 items-center">{prefix}</div>
) : null}
<CommandPrimitive.Input
ref={ref}
className={cn(

View File

@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}