feat(desktop): enable multi-window support and new window action (#377)

* feat(macos): add New Window menu item

Add a New Window option to the macOS menu bar
Trigger opening a separate window when the New Window action is selected

* feat: enable multiple desktop windows and new window shortcut

Enable multiple desktop windows by extending manifest to main-*
Expose desktop_new_window to create additional windows from UI
Show New Window shortcut in Help dialog (Desktop only) with Shift + Alt + <mod> + N

* feat(desktop): enable opening host URL in new window from host switcher

Add a new action to launch a URL in a separate window from the host switcher
Wire the UI to open the selected host in a new window using a dedicated control
Expose a cross-platform API to create a new window pointing at a URL

* fix(desktop): validate URL scheme before opening new window

* fix(desktop): skip unreachable hosts when opening new windows
This commit is contained in:
Iuliia Ivashko
2026-02-10 02:07:04 +02:00
committed by GitHub
parent f91d048a2b
commit 89116483b7
5 changed files with 353 additions and 85 deletions
@@ -29,6 +29,7 @@ import {
RiStarFill,
RiStarLine,
RiDeleteBinLine,
RiWindowLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
@@ -37,6 +38,7 @@ import {
desktopHostProbe,
desktopHostsGet,
desktopHostsSet,
desktopOpenNewWindowAtUrl,
type DesktopHost,
type HostProbeResult,
} from '@/lib/desktopHosts';
@@ -355,6 +357,17 @@ export function DesktopHostSwitcherDialog({
await persist(configHosts, next);
}, [configHosts, persist]);
const openInNewWindow = React.useCallback((host: DesktopHost) => {
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
if (!origin) return;
const target = toNavigationUrl(origin);
desktopOpenNewWindowAtUrl(target).catch((err: unknown) => {
toast.error('Failed to open new window', {
description: err instanceof Error ? err.message : String(err),
});
});
}, []);
if (!isDesktopShell()) {
return null;
}
@@ -486,28 +499,6 @@ export function DesktopHostSwitcherDialog({
</button>
<div className="flex items-center gap-2 flex-shrink-0">
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
isDefault
? 'text-primary hover:text-primary/80'
: 'text-muted-foreground/60 hover:text-primary/80',
)}
onClick={() => void setDefault(host.id)}
aria-label={isDefault ? 'Default instance' : 'Set as default'}
disabled={isSaving}
>
{isDefault ? <RiStarFill className="h-4 w-4" /> : <RiStarLine className="h-4 w-4" />}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{isDefault ? 'Default' : 'Set as default'}
</TooltipContent>
</Tooltip>
{!isLocal && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -553,6 +544,53 @@ export function DesktopHostSwitcherDialog({
aria-hidden="true"
/>
)}
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
isDefault
? 'text-primary hover:text-primary/80'
: 'text-muted-foreground/60 hover:text-primary/80',
)}
onClick={() => void setDefault(host.id)}
aria-label={isDefault ? 'Default instance' : 'Set as default'}
disabled={isSaving}
>
{isDefault ? <RiStarFill className="h-4 w-4" /> : <RiStarLine className="h-4 w-4" />}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{isDefault ? 'Default' : 'Set as default'}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
status?.status === 'unreachable'
? 'text-muted-foreground/30 cursor-not-allowed'
: 'text-muted-foreground/60 hover:text-foreground',
)}
onClick={(e) => {
e.stopPropagation();
openInNewWindow(host);
}}
disabled={status?.status === 'unreachable'}
aria-label="Open in new window"
>
<RiWindowLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{status?.status === 'unreachable' ? 'Instance unreachable' : 'Open in new window'}
</TooltipContent>
</Tooltip>
</div>
</div>
);
@@ -26,6 +26,7 @@ import {
RiTerminalBoxLine,
RiText,
RiTimeLine,
RiWindowLine,
} from "@remixicon/react";
import { getModifierLabel } from "@/lib/utils";
@@ -124,6 +125,11 @@ export const HelpDialog: React.FC = () => {
description: "Cycle Thinking Variant",
icon: RiBrainAi3Line,
},
{
keys: [`Shift + Alt + ${mod} + N`],
description: "New Window (desktop only)",
icon: RiWindowLine,
},
],
},
{
+6
View File
@@ -108,3 +108,9 @@ export const desktopHostProbe = async (url: string): Promise<HostProbeResult> =>
const latencyMs = readNumber(raw, 'latencyMs') ?? readNumber(raw, 'latency_ms') ?? 0;
return { status, latencyMs };
};
export const desktopOpenNewWindowAtUrl = async (url: string): Promise<void> => {
const invoke = getInvoke();
if (!invoke) return;
await invoke('desktop_new_window_at_url', { url });
};