Files
grimoire/src/components/CommandLauncher.tsx
Alejandro 2ad3f90174 Improve mobile UX with larger touch targets (#223)
- Add useIsMobile hook for viewport detection
- TabBar: larger height (48px), disable reorder on mobile, hide workspace numbers
- Header buttons: larger touch targets for SpellbookDropdown, UserMenu, LayoutControls
- Window toolbar: larger buttons (40px) on mobile
- Mosaic dividers: wider (12px) on mobile for easier dragging
- CommandLauncher: larger items, footer text, hide kbd hints on mobile
- Editor suggestions: responsive widths, min-height 44px touch targets
- EventFooter: larger kind/relay buttons on mobile
- CodeCopyButton: larger padding and icon on mobile
- MembersDropdown: larger trigger and list items on mobile

All changes use mobile-first Tailwind (base styles for mobile, md: for desktop)
to meet Apple HIG 44px minimum touch target recommendation.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:19:31 +01:00

272 lines
9.1 KiB
TypeScript

import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { useAtom } from "jotai";
import { useLiveQuery } from "dexie-react-hooks";
import { useNavigate, useLocation } from "react-router";
import db from "@/services/db";
import { useGrimoire } from "@/core/state";
import { manPages } from "@/types/man";
import { parseCommandInput, executeCommandParser } from "@/lib/command-parser";
import { commandLauncherEditModeAtom } from "@/core/command-launcher-state";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
import "./command-launcher.css";
/** Check if current path doesn't have the window system (should navigate to / when launching commands) */
function isNonDashboardRoute(pathname: string): boolean {
// /run route - pop-out command page
if (pathname.startsWith("/run")) return true;
// NIP-19 preview routes are single-segment paths starting with npub1, note1, nevent1, naddr1
const segment = pathname.slice(1); // Remove leading /
if (segment.includes("/")) return false; // Multi-segment paths are not NIP-19 previews
return (
segment.startsWith("npub1") ||
segment.startsWith("note1") ||
segment.startsWith("nevent1") ||
segment.startsWith("naddr1")
);
}
interface CommandLauncherProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function CommandLauncher({
open,
onOpenChange,
}: CommandLauncherProps) {
const [input, setInput] = useState("");
const [editMode, setEditMode] = useAtom(commandLauncherEditModeAtom);
const { state, addWindow, updateWindow } = useGrimoire();
const navigate = useNavigate();
const location = useLocation();
// Fetch spells with aliases
const aliasedSpells =
useLiveQuery(() =>
db.spells
.toArray()
.then((spells) =>
spells.filter((s) => s.alias !== undefined && s.alias !== ""),
),
) || [];
// Prefill input when entering edit mode
useEffect(() => {
if (open && editMode) {
setInput(editMode.initialCommand);
} else if (!open) {
// Clear input and edit mode when dialog closes
setInput("");
setEditMode(null);
}
}, [open, editMode, setEditMode]);
// Parse input into command and arguments
const parsed = parseCommandInput(input);
const { commandName } = parsed;
// Check if it's a spell alias
const activeSpell = aliasedSpells.find(
(s) => s.alias?.toLowerCase() === commandName.toLowerCase(),
);
// Re-parse if it's a spell
const effectiveParsed = activeSpell
? parseCommandInput(
activeSpell.command +
(input.trim().includes(" ")
? " " + input.trim().split(/\s+/).slice(1).join(" ")
: ""),
)
: parsed;
const recognizedCommand = effectiveParsed.command;
// Filter commands by partial match on command name only
const filteredCommands = [
...Object.entries(manPages),
...aliasedSpells.map((s) => [
s.alias!,
{
name: s.alias!,
synopsis: s.alias!,
description: s.name || s.description || "",
category: "Spells",
appId: "req",
spellCommand: s.command,
} as any,
]),
].filter(([name]) => name.toLowerCase().includes(commandName.toLowerCase()));
// Execute command (async to support async argParsers)
const executeCommand = async () => {
if (!recognizedCommand) return;
// Execute argParser and get props/title
const result = await executeCommandParser(
effectiveParsed,
state.activeAccount?.pubkey,
);
if (result.error || !result.props) {
console.error("Failed to parse command:", result.error);
return;
}
// Edit mode: update existing window
if (editMode) {
updateWindow(editMode.windowId, {
props: result.props,
commandString: activeSpell ? effectiveParsed.fullInput : input.trim(),
appId: recognizedCommand.appId,
customTitle: result.globalFlags?.windowProps?.title,
});
setEditMode(null); // Clear edit mode
} else {
// If on a non-dashboard route (no window system), navigate to dashboard first
// The window will appear after navigation since state persists
if (isNonDashboardRoute(location.pathname)) {
navigate("/");
}
// Normal mode: create new window
addWindow(
recognizedCommand.appId,
result.props,
activeSpell ? effectiveParsed.fullInput : input.trim(),
result.globalFlags?.windowProps?.title,
activeSpell?.id,
);
}
onOpenChange(false);
};
// Handle item selection (populate input, don't execute)
const handleSelect = (selectedCommand: string) => {
setInput(selectedCommand + " ");
};
// Handle Enter key
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
executeCommand();
}
};
// Define category order: Nostr first, then Spells, then Documentation, then System
const categoryOrder = ["Nostr", "Spells", "Documentation", "System"];
const categories = Array.from(
new Set(filteredCommands.map(([_, cmd]) => cmd.category)),
).sort((a, b) => {
const indexA = categoryOrder.indexOf(a as string);
const indexB = categoryOrder.indexOf(b as string);
return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB);
});
// Dynamic placeholder
const placeholder = recognizedCommand
? activeSpell
? activeSpell.command
: recognizedCommand.synopsis
: "Type a command...";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="grimoire-command-launcher p-0">
<VisuallyHidden>
<DialogTitle>Command Launcher</DialogTitle>
</VisuallyHidden>
<Command
label="Command Launcher"
className="grimoire-command-content"
shouldFilter={false}
>
<div className="command-launcher-wrapper">
<Command.Input
value={input}
onValueChange={setInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="command-input"
autoFocus
/>
<Command.List className="command-list">
<Command.Empty className="command-empty">
{commandName
? `No command found: ${commandName}`
: "Start typing..."}
</Command.Empty>
{categories.map((category) => (
<Command.Group
key={category}
heading={category}
className="command-group"
>
{filteredCommands
.filter(([_, cmd]) => cmd.category === category)
.map(([name, cmd]) => {
const isExactMatch = name === commandName;
return (
<Command.Item
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="command-item"
data-exact-match={isExactMatch}
>
<div className="command-item-content">
<div className="command-item-name">
<span className="command-name">{name}</span>
{cmd.synopsis !== name && (
<span className="command-args">
{cmd.synopsis.replace(name, "").trim()}
</span>
)}
{isExactMatch && (
<span className="command-match-indicator">
</span>
)}
</div>
{cmd.description && (
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
)}
{cmd.spellCommand && (
<div className="text-xs md:text-[10px] opacity-50 font-mono truncate mt-0.5">
{cmd.spellCommand}
</div>
)}
</div>
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="command-footer">
<div className="hidden md:block">
<kbd></kbd> navigate
<kbd></kbd> execute
<kbd>esc</kbd> close
</div>
{recognizedCommand && (
<div className="command-footer-status">Ready to execute</div>
)}
</div>
</div>
</Command>
</DialogContent>
</Dialog>
);
}