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
@@ -12,7 +12,7 @@
"https://*/*"
]
},
"windows": ["main"],
"windows": ["main", "main-*"],
"permissions": [
"core:default",
"core:window:default",
+280 -62
View File
@@ -6,19 +6,50 @@ use serde::{Deserialize, Serialize};
use std::{
net::TcpListener,
process::Command,
sync::Mutex,
sync::{atomic::{AtomicU64, Ordering}, Mutex},
time::Duration,
};
use std::{collections::{HashMap, HashSet}, fs, path::{Path, PathBuf}};
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tauri::utils::config::BackgroundThrottlingPolicy;
fn eval_in_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>, script: &str) {
let Some(window) = app.get_webview_window("main") else {
return;
};
let _ = window.eval(script);
/// Global counter for generating unique window labels.
static WINDOW_COUNTER: AtomicU64 = AtomicU64::new(1);
fn next_window_label() -> String {
let n = WINDOW_COUNTER.fetch_add(1, Ordering::Relaxed);
if n == 1 {
"main".to_string()
} else {
format!("main-{n}")
}
}
/// Evaluate a script in all open webview windows.
fn eval_in_all_windows<R: tauri::Runtime>(app: &tauri::AppHandle<R>, script: &str) {
for window in app.webview_windows().values() {
let _ = window.eval(script);
}
}
/// Evaluate a script in the currently focused window, falling back to any window.
fn eval_in_focused_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>, script: &str) {
let windows = app.webview_windows();
// Try the focused window first.
for window in windows.values() {
if window.is_focused().unwrap_or(false) {
let _ = window.eval(script);
return;
}
}
// Fallback: try "main", then any window.
if let Some(window) = windows.get("main") {
let _ = window.eval(script);
} else if let Some(window) = windows.values().next() {
let _ = window.eval(script);
}
}
fn dispatch_menu_action<R: tauri::Runtime>(app: &tauri::AppHandle<R>, action: &str) {
@@ -28,7 +59,7 @@ fn dispatch_menu_action<R: tauri::Runtime>(app: &tauri::AppHandle<R>, action: &s
.unwrap_or_else(|_| "\"openchamber:menu-action\"".into());
let detail = serde_json::to_string(action).unwrap_or_else(|_| "\"\"".into());
let script = format!("window.dispatchEvent(new CustomEvent({event}, {{ detail: {detail} }}));");
eval_in_main_window(app, &script);
eval_in_focused_window(app, &script);
}
fn dispatch_check_for_updates<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
@@ -37,7 +68,7 @@ fn dispatch_check_for_updates<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
let event = serde_json::to_string("openchamber:check-for-updates")
.unwrap_or_else(|_| "\"openchamber:check-for-updates\"".into());
let script = format!("window.dispatchEvent(new Event({event}));");
eval_in_main_window(app, &script);
eval_in_all_windows(app, &script);
}
use tauri_plugin_shell::{process::CommandChild, process::CommandEvent, ShellExt};
use tauri_plugin_updater::UpdaterExt;
@@ -47,6 +78,8 @@ const MENU_ITEM_ABOUT_ID: &str = "menu_about";
#[cfg(target_os = "macos")]
const MENU_ITEM_CHECK_FOR_UPDATES_ID: &str = "menu_check_for_updates";
#[cfg(target_os = "macos")]
const MENU_ITEM_NEW_WINDOW_ID: &str = "menu_new_window";
#[cfg(target_os = "macos")]
const MENU_ITEM_SETTINGS_ID: &str = "menu_settings";
#[cfg(target_os = "macos")]
const MENU_ITEM_COMMAND_PALETTE_ID: &str = "menu_command_palette";
@@ -138,6 +171,14 @@ fn build_macos_menu<R: tauri::Runtime>(
Some("Cmd+K"),
)?;
let new_window = MenuItem::with_id(
app,
MENU_ITEM_NEW_WINDOW_ID,
"New Window",
true,
Some("Cmd+Shift+Alt+N"),
)?;
let new_session = MenuItem::with_id(
app,
MENU_ITEM_NEW_SESSION_ID,
@@ -286,6 +327,8 @@ fn build_macos_menu<R: tauri::Runtime>(
"File",
true,
&[
&new_window,
&PredefinedMenuItem::separator(app)?,
&new_session,
&worktree_creator,
&PredefinedMenuItem::separator(app)?,
@@ -715,7 +758,7 @@ fn dispatch_installed_apps_update(app: &tauri::AppHandle, apps: &[InstalledAppIn
.unwrap_or_else(|_| "\"openchamber:installed-apps-updated\"".into());
let detail = serde_json::to_string(apps).unwrap_or_else(|_| "[]".into());
let script = format!("window.dispatchEvent(new CustomEvent({event}, {{ detail: {detail} }}));");
eval_in_main_window(app, &script);
eval_in_all_windows(app, &script);
}
#[cfg(target_os = "macos")]
@@ -896,23 +939,51 @@ struct SidecarState {
url: Mutex<Option<String>>,
}
/// Holds the initialization script and local origin, shared across all windows.
#[derive(Default)]
struct DesktopUiInjectionState {
script: Mutex<Option<String>>,
local_origin: Mutex<Option<String>>,
/// Host URLs that were probed unreachable (e.g. at startup).
/// `open_new_window` checks this to avoid opening windows at dead hosts.
unreachable_hosts: Mutex<HashSet<String>>,
}
/// Tracks the set of currently-focused window labels.
/// Notification suppression triggers when ANY window is focused.
struct WindowFocusState {
focused: Mutex<bool>,
focused_windows: Mutex<HashSet<String>>,
}
impl Default for WindowFocusState {
fn default() -> Self {
Self {
focused: Mutex::new(true),
focused_windows: Mutex::new(HashSet::new()),
}
}
}
impl WindowFocusState {
fn any_focused(&self) -> bool {
let guard = self.focused_windows.lock().expect("focus mutex");
!guard.is_empty()
}
fn set_focused(&self, label: &str, focused: bool) {
let mut guard = self.focused_windows.lock().expect("focus mutex");
if focused {
guard.insert(label.to_string());
} else {
guard.remove(label);
}
}
fn remove_window(&self, label: &str) {
let mut guard = self.focused_windows.lock().expect("focus mutex");
guard.remove(label);
}
}
#[derive(Default)]
struct MenuRuntimeState {
auto_worktree: Mutex<bool>,
@@ -1291,11 +1362,11 @@ struct SidecarNotifyPayload {
fn maybe_show_sidecar_notification(app: &tauri::AppHandle, payload: SidecarNotifyPayload) {
let require_hidden = payload.require_hidden.unwrap_or(false);
if require_hidden {
let focused = app
let any_focused = app
.try_state::<WindowFocusState>()
.map(|state| *state.focused.lock().expect("focus mutex"))
.map(|state| state.any_focused())
.unwrap_or(false);
if focused {
if any_focused {
return;
}
}
@@ -1740,51 +1811,83 @@ fn desktop_restart(app: tauri::AppHandle) {
app.restart();
}
fn create_main_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Result<()> {
let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?;
/// Create a new desktop window from the UI layer.
///
/// IMPORTANT: This command MUST remain synchronous (not `async`). Tauri runs
/// sync commands on the main thread, which is required on macOS for
/// `WebviewWindowBuilder::build()`. Making this `async` would move execution
/// to the Tokio thread pool and risk crashes or undefined behavior.
#[tauri::command]
fn desktop_new_window(app: tauri::AppHandle) -> Result<(), String> {
open_new_window(&app);
Ok(())
}
let home = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_default();
#[cfg(target_os = "macos")]
fn macos_major_version() -> Option<u32> {
fn cmd_stdout(cmd: &str, args: &[&str]) -> Option<String> {
let output = Command::new(cmd).args(args).output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
/// Open a new window pointed at a specific URL (used by the host switcher UI).
///
/// IMPORTANT: Must remain synchronous -- see `desktop_new_window` doc comment.
#[tauri::command]
fn desktop_new_window_at_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
// Validate scheme to prevent file://, data:, javascript: etc.
let parsed = url::Url::parse(&url).map_err(|e| format!("Invalid URL: {e}"))?;
match parsed.scheme() {
"http" | "https" => {}
scheme => return Err(format!("Unsupported URL scheme: {scheme}")),
}
let local_origin = app
.try_state::<DesktopUiInjectionState>()
.and_then(|state| state.local_origin.lock().expect("desktop local origin mutex").clone())
.ok_or_else(|| "Local origin not yet known (sidecar may still be starting)".to_string())?;
create_window(&app, &url, &local_origin).map_err(|e| e.to_string())
}
#[cfg(target_os = "macos")]
fn macos_major_version() -> Option<u32> {
fn cmd_stdout(cmd: &str, args: &[&str]) -> Option<String> {
let output = Command::new(cmd).args(args).output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
// Use marketing version (sw_vers), but map legacy 10.x to minor (10.15 -> 15).
// This matches WebKit UA fallback logic in the UI.
if let Some(raw) = cmd_stdout("/usr/bin/sw_vers", &["-productVersion"]).or_else(|| cmd_stdout("sw_vers", &["-productVersion"])) {
let raw = raw.trim();
let mut parts = raw.split('.');
let major = parts.next().and_then(|v| v.parse::<u32>().ok())?;
let minor = parts.next().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
return Some(if major == 10 { minor } else { major });
}
// Fallback: derive from Darwin major (kern.osrelease major).
let raw = cmd_stdout("/usr/sbin/sysctl", &["-n", "kern.osrelease"])
.or_else(|| cmd_stdout("sysctl", &["-n", "kern.osrelease"]))
.or_else(|| cmd_stdout("/usr/bin/uname", &["-r"]))
.or_else(|| cmd_stdout("uname", &["-r"]))?;
// Use marketing version (sw_vers), but map legacy 10.x to minor (10.15 -> 15).
// This matches WebKit UA fallback logic in the UI.
if let Some(raw) = cmd_stdout("/usr/bin/sw_vers", &["-productVersion"]).or_else(|| cmd_stdout("sw_vers", &["-productVersion"])) {
let raw = raw.trim();
let major = raw.split('.').next()?.parse::<u32>().ok()?;
if major >= 20 {
return Some(major - 9);
}
if major >= 15 {
return Some(major - 4);
}
Some(major)
let mut parts = raw.split('.');
let major = parts.next().and_then(|v| v.parse::<u32>().ok())?;
let minor = parts.next().and_then(|v| v.parse::<u32>().ok()).unwrap_or(0);
return Some(if major == 10 { minor } else { major });
}
#[cfg(not(target_os = "macos"))]
fn macos_major_version() -> Option<u32> {
None
// Fallback: derive from Darwin major (kern.osrelease major).
let raw = cmd_stdout("/usr/sbin/sysctl", &["-n", "kern.osrelease"])
.or_else(|| cmd_stdout("sysctl", &["-n", "kern.osrelease"]))
.or_else(|| cmd_stdout("/usr/bin/uname", &["-r"]))
.or_else(|| cmd_stdout("uname", &["-r"]))?;
let raw = raw.trim();
let major = raw.split('.').next()?.parse::<u32>().ok()?;
if major >= 20 {
return Some(major - 9);
}
if major >= 15 {
return Some(major - 4);
}
Some(major)
}
#[cfg(not(target_os = "macos"))]
fn macos_major_version() -> Option<u32> {
None
}
/// Build the initialization script injected into every webview window.
/// This is computed once and reused for all windows.
fn build_init_script(local_origin: &str) -> String {
let home = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_default();
let macos_major = macos_major_version().unwrap_or(0);
let home_json = serde_json::to_string(&home).unwrap_or_else(|_| "\"\"".into());
@@ -1802,16 +1905,29 @@ fn create_main_window(app: &tauri::AppHandle, url: &str, local_origin: &str) ->
init_script.push_str("\ntry{document.addEventListener('contextmenu',function(e){e.preventDefault();},true);}catch(_e){}");
}
init_script
}
/// Create a new window with a unique label, pointing at the given URL.
fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Result<()> {
let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?;
let label = next_window_label();
let init_script = build_init_script(local_origin);
// Store the init script and local origin so new windows and page reloads can reuse it.
if let Some(state) = app.try_state::<DesktopUiInjectionState>() {
*state.script.lock().expect("desktop ui injection mutex") = Some(init_script.clone());
*state.local_origin.lock().expect("desktop local origin mutex") = Some(local_origin.to_string());
}
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(parsed))
let mut builder = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed))
.title("OpenChamber")
.inner_size(1280.0, 800.0)
.decorations(true)
.visible(false)
.initialization_script(&init_script)
.background_throttling(BackgroundThrottlingPolicy::Disabled)
;
#[cfg(target_os = "macos")]
@@ -1830,6 +1946,78 @@ fn create_main_window(app: &tauri::AppHandle, url: &str, local_origin: &str) ->
Ok(())
}
/// Open a new window pointed at the default host (local or configured default).
///
/// Known multi-window limitations (acceptable for v1):
///
/// - **localStorage conflicts**: Windows sharing the same origin (e.g. both on local)
/// share `localStorage`. Zustand `persist` middleware writes full state blobs on every
/// change with no cross-tab sync, so concurrent windows can overwrite each other's
/// persisted UI preferences, session selections, and model/agent choices.
/// Server-side data is unaffected. Scoping storage keys per window or adding
/// `storage` event listeners would fix this in a future iteration.
///
/// - **Duplicate SSE connections**: Each window opens its own SSE connection to
/// `/api/global/event`, resulting in N connections for N windows. Each window
/// independently processes all events and may show duplicate toast notifications.
/// A SharedWorker, BroadcastChannel leader-election, or Rust-side SSE relay
/// would consolidate this in a future iteration.
///
/// - **Startup race**: If called before the sidecar finishes starting (local_origin
/// not yet set), this function silently bails with a log warning. The user sees
/// no feedback from clicking the dock icon during the startup window (~0-20s).
fn open_new_window(app: &tauri::AppHandle) {
let local_origin = app
.try_state::<DesktopUiInjectionState>()
.and_then(|state| state.local_origin.lock().expect("desktop local origin mutex").clone());
let Some(local_origin) = local_origin else {
log::warn!("[desktop] cannot open new window: local origin not yet known (sidecar may still be starting)");
return;
};
// Resolve the URL the same way as initial setup: default host or local.
let local_url = app
.try_state::<SidecarState>()
.and_then(|state| state.url.lock().expect("sidecar url mutex").clone())
.unwrap_or_else(|| local_origin.clone());
let local_ui_url = if cfg!(debug_assertions) {
// In dev mode, prefer the Vite dev server if it was used as local origin.
local_origin.clone()
} else {
local_url.clone()
};
let mut target_url = local_ui_url.clone();
let cfg = read_desktop_hosts_config_from_disk();
if let Some(default_id) = cfg.default_host_id {
if default_id == LOCAL_HOST_ID {
target_url = local_ui_url.clone();
} else if let Some(host) = cfg.hosts.into_iter().find(|h| h.id == default_id) {
target_url = host.url;
}
}
// If this host was previously probed unreachable (e.g. at startup), fall back to local.
if target_url != local_ui_url {
let is_cached_unreachable = app
.try_state::<DesktopUiInjectionState>()
.map(|state| state.unreachable_hosts.lock().expect("unreachable hosts mutex").contains(&target_url))
.unwrap_or(false);
if is_cached_unreachable {
log::info!("[desktop] new window: default host ({}) cached as unreachable, using local", target_url);
target_url = local_ui_url;
}
}
if let Err(err) = create_window(app, &target_url, &local_origin) {
log::error!("[desktop] failed to create new window: {err}");
}
}
fn main() {
let log_builder = tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
@@ -1880,7 +2068,12 @@ fn main() {
#[cfg(debug_assertions)]
{
let msg = serde_json::to_string(id).unwrap_or_else(|_| "\"(unserializable)\"".into());
eval_in_main_window(app, &format!("console.log('[menu] id=', {});", msg));
eval_in_focused_window(app, &format!("console.log('[menu] id=', {});", msg));
}
if id == MENU_ITEM_NEW_WINDOW_ID {
open_new_window(app);
return;
}
if id == MENU_ITEM_CHECK_FOR_UPDATES_ID {
@@ -1991,19 +2184,27 @@ fn main() {
}
})
.on_window_event(|window, event| {
let app = window.app_handle();
let label = window.label().to_string();
if let tauri::WindowEvent::Focused(focused) = event {
let app = window.app_handle();
if let Some(state) = app.try_state::<WindowFocusState>() {
*state.focused.lock().expect("focus mutex") = *focused;
state.set_focused(&label, *focused);
}
}
#[cfg(target_os = "macos")]
if let tauri::WindowEvent::CloseRequested { .. } = event {
// Ensure sidecar is not left running when users close the main window.
let app = window.app_handle();
kill_sidecar(app.clone());
app.exit(0);
if let tauri::WindowEvent::Destroyed = event {
// Clean up focus tracking for the destroyed window.
if let Some(state) = app.try_state::<WindowFocusState>() {
state.remove_window(&label);
}
// If this was the last window, kill the sidecar and exit.
let remaining = app.webview_windows().len();
if remaining == 0 {
kill_sidecar(app.clone());
app.exit(0);
}
}
})
.invoke_handler(tauri::generate_handler![
@@ -2011,6 +2212,8 @@ fn main() {
desktop_check_for_updates,
desktop_download_and_install_update,
desktop_restart,
desktop_new_window,
desktop_new_window_at_url,
desktop_set_auto_worktree_menu,
desktop_open_path,
desktop_filter_installed_apps,
@@ -2088,6 +2291,7 @@ fn main() {
}
if initial_url != local_ui_url {
let failed_url = initial_url.clone();
match desktop_host_probe(initial_url.clone()).await {
Ok(probe) if probe.status != "unreachable" => {}
Ok(_) => {
@@ -2097,6 +2301,10 @@ fn main() {
local_ui_url
);
initial_url = local_ui_url.clone();
// Cache the failure so open_new_window skips this host.
if let Some(state) = handle.try_state::<DesktopUiInjectionState>() {
state.unreachable_hosts.lock().expect("unreachable hosts mutex").insert(failed_url);
}
}
Err(err) => {
log::warn!(
@@ -2106,11 +2314,14 @@ fn main() {
local_ui_url
);
initial_url = local_ui_url.clone();
if let Some(state) = handle.try_state::<DesktopUiInjectionState>() {
state.unreachable_hosts.lock().expect("unreachable hosts mutex").insert(failed_url);
}
}
}
}
if let Err(err) = create_main_window(&handle, &initial_url, &local_origin) {
if let Err(err) = create_window(&handle, &initial_url, &local_origin) {
log::error!("[desktop] failed to create window: {err}");
}
});
@@ -2132,6 +2343,13 @@ fn main() {
tauri::RunEvent::Exit => {
kill_sidecar(app_handle.clone());
}
#[cfg(target_os = "macos")]
tauri::RunEvent::Reopen { has_visible_windows, .. } => {
// macOS: clicking dock icon when no windows are open opens a new one.
if !has_visible_windows {
open_new_window(app_handle);
}
}
_ => {}
}
});
@@ -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 });
};