From c79147ff7287a32358b4c7df2accd9674cc0ab62 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Wed, 8 Apr 2026 22:03:16 -0400 Subject: [PATCH 1/2] Add command palette with Cmd+K shortcut and extensible registry Introduces a global command palette (cmdk) triggered via Cmd+K with: - Searchable navigation across pages, repos, PRs, and issues from cache - Status-aware icons and colors for PRs (draft/open/merged/closed) and issues - Metadata display (repo, comments, stars, timestamps) sorted by updated date - Chord keyboard shortcuts (G+H, G+P, G+I, G+R) for quick page navigation - Extensible command registry pattern for future actions (create issue, AI prompts) --- .../src/components/command-palette.tsx | 108 +++++++++++ .../components/layouts/dashboard-layout.tsx | 2 + .../src/lib/command-palette/registry.ts | 74 ++++++++ .../src/lib/command-palette/types.ts | 26 +++ .../lib/command-palette/use-command-items.ts | 176 ++++++++++++++++++ .../command-palette/use-command-palette.ts | 80 ++++++++ packages/ui/src/components/command.tsx | 25 ++- 7 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 apps/dashboard/src/components/command-palette.tsx create mode 100644 apps/dashboard/src/lib/command-palette/registry.ts create mode 100644 apps/dashboard/src/lib/command-palette/types.ts create mode 100644 apps/dashboard/src/lib/command-palette/use-command-items.ts create mode 100644 apps/dashboard/src/lib/command-palette/use-command-palette.ts diff --git a/apps/dashboard/src/components/command-palette.tsx b/apps/dashboard/src/components/command-palette.tsx new file mode 100644 index 0000000..a379828 --- /dev/null +++ b/apps/dashboard/src/components/command-palette.tsx @@ -0,0 +1,108 @@ +import { CommentIcon, StarIcon } from "@diffkit/icons"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem as CommandItemUI, + CommandList, + CommandShortcut, +} from "@diffkit/ui/components/command"; +import { cn } from "@diffkit/ui/lib/utils"; +import { useRouter } from "@tanstack/react-router"; +import { formatRelativeTime } from "#/components/pulls/pull-request-row"; +import type { CommandItem, CommandItemMeta } from "#/lib/command-palette/types"; +import { useCommandItems } from "#/lib/command-palette/use-command-items"; +import { useCommandPalette } from "#/lib/command-palette/use-command-palette"; + +export function CommandPalette() { + const { open, setOpen, close } = useCommandPalette(); + const router = useRouter(); + const items = useCommandItems(); + + const groups = new Map(); + for (const item of items) { + const list = groups.get(item.group) ?? []; + list.push(item); + groups.set(item.group, list); + } + + function handleSelect(item: CommandItem) { + close(); + if (item.action.type === "navigate") { + void router.navigate({ to: item.action.to }); + } else { + void item.action.fn(); + } + } + + return ( + + + + No results found. + {Array.from(groups.entries()).map(([groupName, groupItems]) => ( + + {groupItems.map((item) => ( + handleSelect(item)} + > + {item.icon && ( + + )} +
+

{item.label}

+ {item.meta && } +
+ {item.meta?.comments != null && item.meta.comments > 0 && ( + + + {item.meta.comments} + + )} + {item.shortcut && } +
+ ))} +
+ ))} +
+
+ ); +} + +function ItemMeta({ meta }: { meta: CommandItemMeta }) { + const parts: string[] = []; + if (meta.repo) parts.push(meta.repo); + if (meta.language) parts.push(meta.language); + + if (!parts.length && meta.stars == null && !meta.updatedAt) { + return null; + } + + return ( + + {parts.length > 0 && {parts.join(" · ")}} + {meta.stars != null && meta.stars > 0 && ( + <> + {parts.length > 0 && ·} + + + {meta.stars} + + + )} + {meta.updatedAt && ( + <> + {(parts.length > 0 || (meta.stars != null && meta.stars > 0)) && ( + · + )} + {formatRelativeTime(meta.updatedAt)} + + )} + + ); +} diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index ff5be16..4a4fd2b 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { getRouteApi, Outlet } from "@tanstack/react-router"; +import { CommandPalette } from "#/components/command-palette"; import { githubMyIssuesQueryOptions, githubMyPullsQueryOptions, @@ -54,6 +55,7 @@ export function DashboardLayout() { + ); } diff --git a/apps/dashboard/src/lib/command-palette/registry.ts b/apps/dashboard/src/lib/command-palette/registry.ts new file mode 100644 index 0000000..9d15b24 --- /dev/null +++ b/apps/dashboard/src/lib/command-palette/registry.ts @@ -0,0 +1,74 @@ +import { + DashboardIcon, + GitPullRequestIcon, + IssuesIcon, + ReviewsIcon, +} from "@diffkit/icons"; +import type { CommandItem } from "./types"; + +let commands: CommandItem[] = []; +const listeners = new Set<() => void>(); + +function emit() { + for (const listener of listeners) listener(); +} + +export function registerCommands(items: CommandItem[]): () => void { + commands = [...commands, ...items]; + emit(); + return () => { + const ids = new Set(items.map((i) => i.id)); + commands = commands.filter((c) => !ids.has(c.id)); + emit(); + }; +} + +export function getRegisteredCommands(): CommandItem[] { + return commands; +} + +export function subscribeCommands(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +registerCommands([ + { + id: "nav:overview", + label: "Go to Overview", + group: "Pages", + icon: DashboardIcon, + keywords: ["home", "dashboard"], + shortcut: ["G", "H"], + action: { type: "navigate", to: "/" }, + }, + { + id: "nav:pulls", + label: "Go to Pull Requests", + group: "Pages", + icon: GitPullRequestIcon, + keywords: ["pr", "merge"], + shortcut: ["G", "P"], + action: { type: "navigate", to: "/pulls" }, + }, + { + id: "nav:issues", + label: "Go to Issues", + group: "Pages", + icon: IssuesIcon, + keywords: ["bug", "task"], + shortcut: ["G", "I"], + action: { type: "navigate", to: "/issues" }, + }, + { + id: "nav:reviews", + label: "Go to Reviews", + group: "Pages", + icon: ReviewsIcon, + keywords: ["review", "requested"], + shortcut: ["G", "R"], + action: { type: "navigate", to: "/reviews" }, + }, +]); diff --git a/apps/dashboard/src/lib/command-palette/types.ts b/apps/dashboard/src/lib/command-palette/types.ts new file mode 100644 index 0000000..ee181d1 --- /dev/null +++ b/apps/dashboard/src/lib/command-palette/types.ts @@ -0,0 +1,26 @@ +import type { ComponentType } from "react"; + +export type CommandAction = + | { type: "navigate"; to: string } + | { type: "execute"; fn: () => void | Promise }; + +export type CommandItemMeta = { + repo?: string; + comments?: number; + updatedAt?: string; + language?: string | null; + stars?: number; +}; + +export type CommandItem = { + id: string; + label: string; + group: string; + icon?: ComponentType<{ className?: string }>; + iconClassName?: string; + keywords?: string[]; + shortcut?: string[]; + action: CommandAction; + priority?: number; + meta?: CommandItemMeta; +}; diff --git a/apps/dashboard/src/lib/command-palette/use-command-items.ts b/apps/dashboard/src/lib/command-palette/use-command-items.ts new file mode 100644 index 0000000..356c0f0 --- /dev/null +++ b/apps/dashboard/src/lib/command-palette/use-command-items.ts @@ -0,0 +1,176 @@ +import { + CodeIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, + IssuesIcon, +} from "@diffkit/icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { getRouteApi } from "@tanstack/react-router"; +import { useSyncExternalStore } from "react"; +import { githubQueryKeys } from "#/lib/github.query"; +import type { + IssueSummary, + MyIssuesResult, + MyPullsResult, + PullSummary, + UserRepoSummary, +} from "#/lib/github.types"; +import { getRegisteredCommands, subscribeCommands } from "./registry"; +import type { CommandItem } from "./types"; + +function getPrIcon(pr: PullSummary) { + if (pr.isDraft) { + return { + icon: GitPullRequestDraftIcon, + iconClassName: "text-muted-foreground", + }; + } + if (pr.mergedAt) { + return { icon: GitMergeIcon, iconClassName: "text-purple-500" }; + } + if (pr.state === "closed") { + return { icon: GitPullRequestClosedIcon, iconClassName: "text-red-500" }; + } + return { icon: GitPullRequestIcon, iconClassName: "text-green-500" }; +} + +function getIssueIcon(issue: IssueSummary) { + if (issue.state === "closed") { + if (issue.stateReason === "not_planned") { + return { icon: IssuesIcon, iconClassName: "text-muted-foreground" }; + } + return { icon: IssuesIcon, iconClassName: "text-purple-500" }; + } + return { icon: IssuesIcon, iconClassName: "text-green-500" }; +} + +const routeApi = getRouteApi("/_protected"); + +export function useCommandItems(): CommandItem[] { + const { user } = routeApi.useRouteContext(); + const scope = { userId: user.id }; + const queryClient = useQueryClient(); + + const staticCommands = useSyncExternalStore( + subscribeCommands, + getRegisteredCommands, + getRegisteredCommands, + ); + + const repos = queryClient.getQueryData( + githubQueryKeys.repos.list(scope), + ); + const pulls = queryClient.getQueryData( + githubQueryKeys.pulls.mine(scope), + ); + const issues = queryClient.getQueryData( + githubQueryKeys.issues.mine(scope), + ); + + const dynamicItems: CommandItem[] = []; + + if (repos) { + for (const repo of repos) { + dynamicItems.push({ + id: `repo:${repo.id}`, + label: repo.fullName, + group: "Repositories", + icon: CodeIcon, + keywords: [repo.name, repo.owner, repo.language ?? ""].filter(Boolean), + action: { + type: "navigate", + to: `/${repo.owner}/${repo.name}`, + }, + meta: { + language: repo.language, + stars: repo.stars, + updatedAt: repo.updatedAt ?? undefined, + }, + }); + } + } + + if (pulls) { + const seen = new Set(); + const allPulls = [ + ...pulls.authored, + ...pulls.assigned, + ...pulls.reviewRequested, + ...pulls.mentioned, + ...pulls.involved, + ]; + for (const pr of allPulls) { + if (seen.has(pr.id)) continue; + seen.add(pr.id); + const prState = getPrIcon(pr); + dynamicItems.push({ + id: `pull:${pr.id}`, + label: `#${pr.number} ${pr.title}`, + group: "Pull Requests", + icon: prState.icon, + iconClassName: prState.iconClassName, + keywords: [ + pr.repository.name, + pr.repository.owner, + pr.author?.login ?? "", + String(pr.number), + ].filter(Boolean), + action: { + type: "navigate", + to: `/${pr.repository.owner}/${pr.repository.name}/pull/${pr.number}`, + }, + meta: { + repo: pr.repository.fullName, + comments: pr.comments, + updatedAt: pr.updatedAt, + }, + }); + } + } + + if (issues) { + const seen = new Set(); + const allIssues = [ + ...issues.authored, + ...issues.assigned, + ...issues.mentioned, + ]; + for (const issue of allIssues) { + if (seen.has(issue.id)) continue; + seen.add(issue.id); + const issueState = getIssueIcon(issue); + dynamicItems.push({ + id: `issue:${issue.id}`, + label: `#${issue.number} ${issue.title}`, + group: "Issues", + icon: issueState.icon, + iconClassName: issueState.iconClassName, + keywords: [ + issue.repository.name, + issue.repository.owner, + issue.author?.login ?? "", + String(issue.number), + ].filter(Boolean), + action: { + type: "navigate", + to: `/${issue.repository.owner}/${issue.repository.name}/issues/${issue.number}`, + }, + meta: { + repo: issue.repository.fullName, + comments: issue.comments, + updatedAt: issue.updatedAt, + }, + }); + } + } + + dynamicItems.sort((a, b) => { + const aTime = a.meta?.updatedAt ?? ""; + const bTime = b.meta?.updatedAt ?? ""; + return bTime.localeCompare(aTime); + }); + + return [...staticCommands, ...dynamicItems]; +} diff --git a/apps/dashboard/src/lib/command-palette/use-command-palette.ts b/apps/dashboard/src/lib/command-palette/use-command-palette.ts new file mode 100644 index 0000000..4b5df50 --- /dev/null +++ b/apps/dashboard/src/lib/command-palette/use-command-palette.ts @@ -0,0 +1,80 @@ +import { useRouter } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getRegisteredCommands } from "./registry"; + +const CHORD_TIMEOUT_MS = 800; + +function isEditableTarget(e: KeyboardEvent) { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + if ((e.target as HTMLElement)?.isContentEditable) return true; + return false; +} + +export function useCommandPalette() { + const [open, setOpen] = useState(false); + const router = useRouter(); + const chordRef = useRef([]); + const chordTimerRef = useRef>(); + + useEffect(() => { + function resetChord() { + chordRef.current = []; + clearTimeout(chordTimerRef.current); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + resetChord(); + setOpen((prev) => !prev); + return; + } + + if (open || e.metaKey || e.ctrlKey || e.altKey || isEditableTarget(e)) { + return; + } + + const key = e.key.toUpperCase(); + if (key.length !== 1 || !/[A-Z]/.test(key)) { + resetChord(); + return; + } + + chordRef.current = [...chordRef.current, key]; + clearTimeout(chordTimerRef.current); + chordTimerRef.current = setTimeout(resetChord, CHORD_TIMEOUT_MS); + + const pressed = chordRef.current; + const commands = getRegisteredCommands(); + + for (const cmd of commands) { + if (!cmd.shortcut) continue; + const shortcut = cmd.shortcut.map((k) => k.toUpperCase()); + + if (shortcut.length !== pressed.length) continue; + if (!shortcut.every((k, i) => k === pressed[i])) continue; + + e.preventDefault(); + resetChord(); + + if (cmd.action.type === "navigate") { + void router.navigate({ to: cmd.action.to }); + } else { + void cmd.action.fn(); + } + return; + } + } + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + clearTimeout(chordTimerRef.current); + }; + }, [open, router]); + + const close = useCallback(() => setOpen(false), []); + + return { open, setOpen, close }; +} diff --git a/packages/ui/src/components/command.tsx b/packages/ui/src/components/command.tsx index 7aa8c0b..f34bb1a 100644 --- a/packages/ui/src/components/command.tsx +++ b/packages/ui/src/components/command.tsx @@ -44,8 +44,8 @@ function CommandDialog({ {title} {description} - - + + {children} @@ -83,7 +83,7 @@ function CommandList({ ) { +}: React.ComponentProps<"span"> & { + keys?: string[]; +}) { return ( + > + {keys + ? keys.map((key, i) => ( + + {i > 0 && then} + {key} + + )) + : children} + ); } From 91e88ea452a168eb7e43cf2898953774283dac8c Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Wed, 8 Apr 2026 22:08:07 -0400 Subject: [PATCH 2/2] Fix useRef missing initial value for React 19 types --- apps/dashboard/src/lib/command-palette/use-command-palette.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/lib/command-palette/use-command-palette.ts b/apps/dashboard/src/lib/command-palette/use-command-palette.ts index 4b5df50..054da2d 100644 --- a/apps/dashboard/src/lib/command-palette/use-command-palette.ts +++ b/apps/dashboard/src/lib/command-palette/use-command-palette.ts @@ -15,7 +15,7 @@ export function useCommandPalette() { const [open, setOpen] = useState(false); const router = useRouter(); const chordRef = useRef([]); - const chordTimerRef = useRef>(); + const chordTimerRef = useRef>(undefined); useEffect(() => { function resetChord() {