From 59fdfc56115ccfea7fce6bd9582e269f243ad2ce Mon Sep 17 00:00:00 2001 From: Alejandro Date: Wed, 14 Jan 2026 17:30:56 +0100 Subject: [PATCH] Add route for command results in popup window (#93) * feat: Add pop-out command route for standalone window rendering Add a new /run route that allows windows to be opened in separate browser windows/tabs without affecting the main workspace layout. Changes: - Add RunCommandPage component for /run?cmd= route - Add Pop Out button to WindowToolbar (ExternalLink icon) - Parse command from URL query parameter and render result - Construct minimal WindowInstance for rendering - Display command string in header with clean minimal UI This enables users to pop out any window into a separate browser context while maintaining the main workspace layout, useful for multi-monitor setups or keeping reference windows visible. * refactor: Remove header from pop-out command page Simplify RunCommandPage to only show the window renderer without any additional UI chrome. This provides a cleaner, more focused experience for popped-out windows. * refactor: Move pop-out action to window menu dropdown Move the pop-out button from a standalone icon to the three-dot menu dropdown to reduce toolbar clutter. The menu now always appears since pop-out is always available. * feat: Add AppShell header to pop-out command page Wrap RunCommandPage with AppShell (hideBottomBar) to show the header with user menu and command launcher, matching the behavior of NIP-19 preview pages. When a command is launched from the /run page, it navigates to the main dashboard (/) where the window system exists. --------- Co-authored-by: Claude --- src/components/CommandLauncher.tsx | 11 ++- src/components/WindowToolbar.tsx | 121 ++++++++++++++---------- src/components/pages/RunCommandPage.tsx | 101 ++++++++++++++++++++ src/root.tsx | 9 ++ 4 files changed, 190 insertions(+), 52 deletions(-) create mode 100644 src/components/pages/RunCommandPage.tsx diff --git a/src/components/CommandLauncher.tsx b/src/components/CommandLauncher.tsx index 6e4e1f5..50b0226 100644 --- a/src/components/CommandLauncher.tsx +++ b/src/components/CommandLauncher.tsx @@ -12,8 +12,11 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { VisuallyHidden } from "@/components/ui/visually-hidden"; import "./command-launcher.css"; -/** Check if current path is a NIP-19 preview route (no window system) */ -function isNip19PreviewRoute(pathname: string): boolean { +/** 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 @@ -123,9 +126,9 @@ export default function CommandLauncher({ }); setEditMode(null); // Clear edit mode } else { - // If on a NIP-19 preview route (no window system), navigate to dashboard first + // If on a non-dashboard route (no window system), navigate to dashboard first // The window will appear after navigation since state persists - if (isNip19PreviewRoute(location.pathname)) { + if (isNonDashboardRoute(location.pathname)) { navigate("/"); } diff --git a/src/components/WindowToolbar.tsx b/src/components/WindowToolbar.tsx index 9ffa13f..4646e31 100644 --- a/src/components/WindowToolbar.tsx +++ b/src/components/WindowToolbar.tsx @@ -6,6 +6,7 @@ import { Copy, CopyCheck, ArrowRightFromLine, + ExternalLink, } from "lucide-react"; import { useSetAtom } from "jotai"; import { useState } from "react"; @@ -91,6 +92,21 @@ export function WindowToolbar({ setShowSpellDialog(true); }; + const handlePopOut = () => { + if (!window) return; + + // Get command string (existing or reconstructed) + const commandString = window.commandString || reconstructCommand(window); + + // Construct the /run URL with the command as a query parameter + const popOutUrl = `/run?cmd=${encodeURIComponent(commandString)}`; + + // Open in a new window/tab + globalThis.window.open(popOutUrl, "_blank"); + + toast.success("Window popped out"); + }; + // Copy functionality for NIPs const { copy, copied } = useCopy(); const isNipWindow = window?.appId === "nip"; @@ -154,55 +170,64 @@ export function WindowToolbar({ )} - {/* More actions menu - shows when multiple workspaces exist */} - {hasMultipleWorkspaces && ( - - - - - - {/* Move to tab submenu */} - - - - Move to tab - - - {otherWorkspaces.map((ws) => ( - handleMoveToWorkspace(ws.id)} - > - {ws.number} - {ws.label ? ` ${ws.label}` : ""} - - ))} - - + {/* More actions menu */} + + + + + + {/* Pop out window */} + + + Pop out window + - {/* REQ-specific actions */} - {isReqWindow && ( - <> - - - - Save as spell - - - )} - - - )} + {/* Move to tab submenu - only show if multiple workspaces */} + {hasMultipleWorkspaces && ( + <> + + + + + Move to tab + + + {otherWorkspaces.map((ws) => ( + handleMoveToWorkspace(ws.id)} + > + {ws.number} + {ws.label ? ` ${ws.label}` : ""} + + ))} + + + + )} + + {/* REQ-specific actions */} + {isReqWindow && ( + <> + + + + Save as spell + + + )} + + {/* Spell Dialog */} {isReqWindow && ( diff --git a/src/components/pages/RunCommandPage.tsx b/src/components/pages/RunCommandPage.tsx new file mode 100644 index 0000000..94d228b --- /dev/null +++ b/src/components/pages/RunCommandPage.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router"; +import { + parseAndExecuteCommand, + type ParsedCommand, +} from "@/lib/command-parser"; +import { useGrimoire } from "@/core/state"; +import { WindowRenderer } from "@/components/WindowRenderer"; + +/** + * RunCommandPage - Standalone command execution route + * + * Route: /run?cmd= + * + * Executes a command and displays the result without affecting the workspace layout. + * This allows windows to be "popped out" into separate browser windows/tabs. + */ +export default function RunCommandPage() { + const [searchParams] = useSearchParams(); + const { state } = useGrimoire(); + const [parsed, setParsed] = useState(null); + const [loading, setLoading] = useState(true); + + const cmdParam = searchParams.get("cmd"); + + useEffect(() => { + async function parseCommand() { + if (!cmdParam) { + setParsed({ + commandName: "", + args: [], + fullInput: "", + error: "No command provided", + }); + setLoading(false); + return; + } + + try { + const result = await parseAndExecuteCommand( + cmdParam, + state.activeAccount?.pubkey, + ); + setParsed(result); + } catch (error) { + setParsed({ + commandName: "", + args: [], + fullInput: cmdParam, + error: + error instanceof Error ? error.message : "Failed to parse command", + }); + } finally { + setLoading(false); + } + } + + parseCommand(); + }, [cmdParam, state.activeAccount?.pubkey]); + + if (loading) { + return ( +
+
Loading command...
+
+ ); + } + + if (!parsed || parsed.error || !parsed.command || !parsed.props) { + return ( +
+
+

+ Command Error +

+

+ {parsed?.error || "Unknown error"} +

+ {cmdParam && ( +

+ Command: {cmdParam} +

+ )} +
+
+ ); + } + + // Construct a minimal WindowInstance for rendering + const windowInstance = { + id: "pop-out", + appId: parsed.command.appId, + props: parsed.props, + customTitle: parsed.globalFlags?.windowProps?.title, + commandString: parsed.fullInput, + }; + + return ( + window.close()} /> + ); +} diff --git a/src/root.tsx b/src/root.tsx index 9aa0cf0..93e30a5 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -3,6 +3,7 @@ import { AppShell } from "./components/layouts/AppShell"; import DashboardPage from "./components/pages/DashboardPage"; import SpellbookPage from "./components/pages/SpellbookPage"; import Nip19PreviewRouter from "./components/pages/Nip19PreviewRouter"; +import RunCommandPage from "./components/pages/RunCommandPage"; const router = createBrowserRouter([ { @@ -13,6 +14,14 @@ const router = createBrowserRouter([ ), }, + { + path: "/run", + element: ( + + + + ), + }, { path: "/preview/:actor/:identifier", element: (