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=<command> 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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-14 17:30:56 +01:00
committed by GitHub
parent 16764e1aca
commit 59fdfc5611
4 changed files with 190 additions and 52 deletions

View File

@@ -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("/");
}

View File

@@ -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({
</Button>
)}
{/* More actions menu - shows when multiple workspaces exist */}
{hasMultipleWorkspaces && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
size="icon"
className="text-muted-foreground"
title="More actions"
aria-label="More actions"
>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Move to tab submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={otherWorkspaces.length === 0}
>
<ArrowRightFromLine className="size-4 mr-2" />
Move to tab
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{otherWorkspaces.map((ws) => (
<DropdownMenuItem
key={ws.id}
onClick={() => handleMoveToWorkspace(ws.id)}
>
{ws.number}
{ws.label ? ` ${ws.label}` : ""}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* More actions menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
size="icon"
className="text-muted-foreground"
title="More actions"
aria-label="More actions"
>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Pop out window */}
<DropdownMenuItem onClick={handlePopOut}>
<ExternalLink className="size-4 mr-2" />
Pop out window
</DropdownMenuItem>
{/* REQ-specific actions */}
{isReqWindow && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleTurnIntoSpell}>
<WandSparkles className="size-4 mr-2" />
Save as spell
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Move to tab submenu - only show if multiple workspaces */}
{hasMultipleWorkspaces && (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={otherWorkspaces.length === 0}
>
<ArrowRightFromLine className="size-4 mr-2" />
Move to tab
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{otherWorkspaces.map((ws) => (
<DropdownMenuItem
key={ws.id}
onClick={() => handleMoveToWorkspace(ws.id)}
>
{ws.number}
{ws.label ? ` ${ws.label}` : ""}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
{/* REQ-specific actions */}
{isReqWindow && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleTurnIntoSpell}>
<WandSparkles className="size-4 mr-2" />
Save as spell
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Spell Dialog */}
{isReqWindow && (

View File

@@ -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=<command>
*
* 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<ParsedCommand | null>(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 (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-muted-foreground">Loading command...</div>
</div>
);
}
if (!parsed || parsed.error || !parsed.command || !parsed.props) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="max-w-md rounded-lg border border-border bg-card p-6">
<h2 className="mb-2 text-lg font-semibold text-destructive">
Command Error
</h2>
<p className="mb-4 text-sm text-muted-foreground">
{parsed?.error || "Unknown error"}
</p>
{cmdParam && (
<p className="text-xs font-mono text-muted-foreground">
Command: {cmdParam}
</p>
)}
</div>
</div>
);
}
// 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 (
<WindowRenderer window={windowInstance} onClose={() => window.close()} />
);
}

View File

@@ -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([
</AppShell>
),
},
{
path: "/run",
element: (
<AppShell hideBottomBar>
<RunCommandPage />
</AppShell>
),
},
{
path: "/preview/:actor/:identifier",
element: (