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:
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user