feat(nexus): extend command palette search + fix shortcut destructure

CommandPalette now consumes CommandPaletteContext (open/setOpen) and
the internal Cmd+K useEffect is gone — the provider owns the global
listener. The search index gains live groups for conversations (via
chatApi.listConversations) and studio workshops (mapped from the
phase 10 WorkshopSlugs), plus stubbed entries for 8 settings section
anchors that will resolve once phase 13 splits InstanceGeneralSettings
into cards. Recipes are stubbed as a disabled placeholder pending
v1.8's recipe API.

useKeyboardShortcuts: adds `onSearch` to the destructure (the phase
6/11 review noted it was referenced at line 25 but never destructured
at lines 12-17) and adds it to the useEffect dependency list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:23:14 +00:00
parent 45d2a9ff24
commit 9a81f0e22b
2 changed files with 133 additions and 16 deletions

View file

@ -1,12 +1,14 @@
import { useState, useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { useCommandPalette } from "../context/CommandPaletteContext";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { chatApi } from "../api/chat";
import { queryKeys } from "../lib/queryKeys";
import {
CommandDialog,
@ -29,12 +31,37 @@ import {
SquarePen,
Plus,
Search,
Settings,
Sparkles,
MessageSquare,
ChefHat,
} from "lucide-react";
import { Identity } from "./Identity";
import { agentUrl, projectUrl } from "../lib/utils";
import { WORKSHOPS } from "./studio/workshops";
/**
* Phase 14 settings section anchors.
*
* Phase 13 is scheduled to split InstanceGeneralSettings into 8 cards with
* anchor slugs. Until that ships, the palette exposes stub entries that
* route to `/instance/settings/general#<slug>` once Phase 13 lands the
* hash will resolve to the correct card via scrollIntoView, and if it
* hasn't the user lands on the general settings page unchanged.
*/
const SETTINGS_SECTIONS: ReadonlyArray<{ slug: string; label: string }> = [
{ slug: "profile", label: "Profile" },
{ slug: "appearance", label: "Appearance" },
{ slug: "notifications", label: "Notifications" },
{ slug: "keyboard", label: "Keyboard Shortcuts" },
{ slug: "providers", label: "AI Providers" },
{ slug: "voice", label: "Voice" },
{ slug: "integrations", label: "Integrations" },
{ slug: "advanced", label: "Advanced" },
];
export function CommandPalette() {
const [open, setOpen] = useState(false);
const { open, setOpen } = useCommandPalette();
const [query, setQuery] = useState("");
const navigate = useNavigate();
const { selectedCompanyId } = useCompany();
@ -42,17 +69,11 @@ export function CommandPalette() {
const { isMobile, setSidebarOpen } = useSidebar();
const searchQuery = query.trim();
// Close-on-mobile-open side effect lives alongside the palette itself
// because the CommandPaletteProvider doesn't know about the sidebar.
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen(true);
if (isMobile) setSidebarOpen(false);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isMobile, setSidebarOpen]);
if (open && isMobile) setSidebarOpen(false);
}, [open, isMobile, setSidebarOpen]);
useEffect(() => {
if (!open) setQuery("");
@ -86,6 +107,17 @@ export function CommandPalette() {
[allProjects],
);
// Conversations index — Phase 14 extension. Uses a non-company query key
// to avoid colliding with Assistant page's own list cache.
const { data: conversationsData } = useQuery({
queryKey: ["command-palette", "conversations", selectedCompanyId],
queryFn: () =>
chatApi.listConversations(selectedCompanyId!, { limit: 25 }),
enabled: !!selectedCompanyId && open,
staleTime: 30_000,
});
const conversations = conversationsData?.items ?? [];
function go(path: string) {
setOpen(false);
navigate(path);
@ -102,12 +134,15 @@ export function CommandPalette() {
);
return (
<CommandDialog open={open} onOpenChange={(v) => {
<CommandDialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (v && isMobile) setSidebarOpen(false);
}}>
}}
>
<CommandInput
placeholder="Search issues, agents, projects..."
placeholder="Search issues, agents, projects, conversations, settings..."
value={query}
onValueChange={setQuery}
/>
@ -148,6 +183,10 @@ export function CommandPalette() {
<Plus className="mr-2 h-4 w-4" />
Create new project
</CommandItem>
<CommandItem onSelect={() => go("/assistant")}>
<MessageSquare className="mr-2 h-4 w-4" />
New conversation
</CommandItem>
</CommandGroup>
<CommandSeparator />
@ -177,6 +216,14 @@ export function CommandPalette() {
<Bot className="mr-2 h-4 w-4" />
Agents
</CommandItem>
<CommandItem onSelect={() => go("/assistant")}>
<MessageSquare className="mr-2 h-4 w-4" />
Assistant
</CommandItem>
<CommandItem onSelect={() => go("/content-studio")}>
<Sparkles className="mr-2 h-4 w-4" />
Content Studio
</CommandItem>
<CommandItem onSelect={() => go("/costs")}>
<DollarSign className="mr-2 h-4 w-4" />
Costs
@ -244,6 +291,75 @@ export function CommandPalette() {
</CommandGroup>
</>
)}
{conversations.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Conversations">
{conversations.slice(0, 10).map((conv) => (
<CommandItem
key={conv.id}
onSelect={() => go(`/assistant/${conv.id}`)}
>
<MessageSquare className="mr-2 h-4 w-4" />
<span className="flex-1 truncate">
{conv.title || "Untitled conversation"}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
<CommandSeparator />
<CommandGroup heading="Workshops">
{WORKSHOPS.map((w) => (
<CommandItem
key={w.slug}
onSelect={() => go(`/content-studio/${w.slug}`)}
>
<w.icon className="mr-2 h-4 w-4" />
<span className="flex-1 truncate">{w.title}</span>
<span className="text-xs text-muted-foreground ml-2">
{w.subtitle}
</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
{SETTINGS_SECTIONS.map((section) => (
<CommandItem
key={section.slug}
onSelect={() =>
go(`/instance/settings/general#${section.slug}`)
}
>
<Settings className="mr-2 h-4 w-4" />
{section.label}
</CommandItem>
))}
</CommandGroup>
{/* Recipes stubbed. The recipe API is scheduled for v1.8; until it
lands we expose an entry that navigates to the (non-existent)
/recipes route, which currently falls through to NotFound. Once
the API exists this group will switch to useQuery. */}
<CommandSeparator />
<CommandGroup heading="Recipes">
<CommandItem
disabled
onSelect={() => {
/* no-op — v1.8 */
}}
>
<ChefHat className="mr-2 h-4 w-4" />
<span className="flex-1 text-muted-foreground">
Recipes (coming in v1.8)
</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);

View file

@ -14,6 +14,7 @@ export function useKeyboardShortcuts({
onNewIssue,
onToggleSidebar,
onTogglePanel,
onSearch,
}: ShortcutHandlers) {
useEffect(() => {
if (!enabled) return;
@ -52,5 +53,5 @@ export function useKeyboardShortcuts({
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel]);
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel, onSearch]);
}