nexus/ui/src/components/CommandPalette.tsx
Forgotten c2709687b8 feat: server-side issue search, dashboard charts, and inbox badges
Add ILIKE-based issue search across title, identifier, description,
and comments with relevance ranking. Add assigneeUserId filter and
allow agents to return issues to creator. Show assigned issue count
in sidebar badges. Add minCount param to live-runs endpoint. Add
activity charts (run activity, priority, status, success rate) to
dashboard. Improve active agents panel with recent run cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:33:39 -06:00

229 lines
7.3 KiB
TypeScript

import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
CircleDot,
Bot,
Hexagon,
Target,
LayoutDashboard,
Inbox,
DollarSign,
History,
SquarePen,
Plus,
} from "lucide-react";
import { Identity } from "./Identity";
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const navigate = useNavigate();
const { selectedCompanyId } = useCompany();
const { openNewIssue, openNewAgent } = useDialog();
const searchQuery = query.trim();
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen(true);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
if (!open) setQuery("");
}, [open]);
const { data: issues = [] } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open,
});
const { data: searchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery),
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery }),
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
});
const { data: agents = [] } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open,
});
const { data: projects = [] } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open,
});
function go(path: string) {
setOpen(false);
navigate(path);
}
const agentName = (id: string | null) => {
if (!id) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
const visibleIssues = useMemo(
() => (searchQuery.length > 0 ? searchedIssues : issues),
[issues, searchedIssues, searchQuery],
);
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder="Search issues, agents, projects..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Actions">
<CommandItem
onSelect={() => {
setOpen(false);
openNewIssue();
}}
>
<SquarePen className="mr-2 h-4 w-4" />
Create new issue
<span className="ml-auto text-xs text-muted-foreground">C</span>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false);
openNewAgent();
}}
>
<Plus className="mr-2 h-4 w-4" />
Create new agent
</CommandItem>
<CommandItem onSelect={() => go("/projects")}>
<Plus className="mr-2 h-4 w-4" />
Create new project
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Pages">
<CommandItem onSelect={() => go("/dashboard")}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</CommandItem>
<CommandItem onSelect={() => go("/inbox")}>
<Inbox className="mr-2 h-4 w-4" />
Inbox
</CommandItem>
<CommandItem onSelect={() => go("/issues")}>
<CircleDot className="mr-2 h-4 w-4" />
Issues
</CommandItem>
<CommandItem onSelect={() => go("/projects")}>
<Hexagon className="mr-2 h-4 w-4" />
Projects
</CommandItem>
<CommandItem onSelect={() => go("/goals")}>
<Target className="mr-2 h-4 w-4" />
Goals
</CommandItem>
<CommandItem onSelect={() => go("/agents")}>
<Bot className="mr-2 h-4 w-4" />
Agents
</CommandItem>
<CommandItem onSelect={() => go("/costs")}>
<DollarSign className="mr-2 h-4 w-4" />
Costs
</CommandItem>
<CommandItem onSelect={() => go("/activity")}>
<History className="mr-2 h-4 w-4" />
Activity
</CommandItem>
</CommandGroup>
{visibleIssues.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Issues">
{visibleIssues.slice(0, 10).map((issue) => (
<CommandItem
key={issue.id}
value={
searchQuery.length > 0
? `${searchQuery} ${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}`
: undefined
}
keywords={issue.description ? [issue.description] : undefined}
onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)}
>
<CircleDot className="mr-2 h-4 w-4" />
<span className="text-muted-foreground mr-2 font-mono text-xs">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="flex-1 truncate">{issue.title}</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name ? <Identity name={name} size="sm" className="ml-2" /> : null;
})()}
</CommandItem>
))}
</CommandGroup>
</>
)}
{agents.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Agents">
{agents.slice(0, 10).map((agent) => (
<CommandItem key={agent.id} onSelect={() => go(`/agents/${agent.id}`)}>
<Bot className="mr-2 h-4 w-4" />
{agent.name}
<span className="text-xs text-muted-foreground ml-2">{agent.role}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{projects.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Projects">
{projects.slice(0, 10).map((project) => (
<CommandItem key={project.id} onSelect={() => go(`/projects/${project.id}`)}>
<Hexagon className="mr-2 h-4 w-4" />
{project.name}
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
);
}