Open script service URLs in-app or external

Adds a Service URLs setting (ask / in-Paseo / external browser) that
controls where URLs from running scripts open on desktop. First click
prompts with a "don't ask again" checkbox; in-Paseo opens a workspace
browser tab. Closing a browser tab now clears its session partition.
This commit is contained in:
Mohamed Boudra
2026-05-03 15:14:28 +07:00
parent 02adadbbc8
commit f58cfe9008
12 changed files with 257 additions and 18 deletions

View File

@@ -62,6 +62,11 @@ interface DropdownMenuContextValue {
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null); const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
export function useDropdownMenuClose(): () => void {
const { setOpen } = useDropdownMenuContext("useDropdownMenuClose");
return useCallback(() => setOpen(false), [setOpen]);
}
function useDropdownMenuContext(componentName: string): DropdownMenuContextValue { function useDropdownMenuContext(componentName: string): DropdownMenuContextValue {
const ctx = useContext(DropdownMenuContext); const ctx = useContext(DropdownMenuContext);
if (!ctx) { if (!ctx) {

View File

@@ -21,8 +21,22 @@ export interface DesktopDialogOpenOptions {
}>; }>;
} }
export interface DesktopDialogAskWithCheckboxOptions extends DesktopDialogAskOptions {
checkboxLabel: string;
checkboxChecked?: boolean;
}
export interface DesktopDialogAskWithCheckboxResult {
confirmed: boolean;
dontAskAgain: boolean;
}
export interface DesktopDialogBridge { export interface DesktopDialogBridge {
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>; ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
askWithCheckbox?: (
message: string,
options: DesktopDialogAskWithCheckboxOptions,
) => Promise<DesktopDialogAskWithCheckboxResult>;
open?: (options?: DesktopDialogOpenOptions) => Promise<string | string[] | null>; open?: (options?: DesktopDialogOpenOptions) => Promise<string | string[] | null>;
} }
@@ -76,6 +90,7 @@ export interface DesktopBrowserShortcutEvent {
export interface DesktopBrowserBridge { export interface DesktopBrowserBridge {
setActivePane?: (browserId: string | null) => Promise<void>; setActivePane?: (browserId: string | null) => Promise<void>;
clearPartition?: (browserId: string) => Promise<void>;
} }
export interface DesktopInvokeBridge { export interface DesktopInvokeBridge {

View File

@@ -1,6 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { queryClient as appQueryClient } from "@/query/query-client";
import { import {
DEFAULT_DESKTOP_SETTINGS, DEFAULT_DESKTOP_SETTINGS,
loadDesktopSettings, loadDesktopSettings,
@@ -16,12 +17,15 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export type SendBehavior = "interrupt" | "queue"; export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta"; export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]); const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
export interface AppSettings { export interface AppSettings {
theme: ThemeName | "auto"; theme: ThemeName | "auto";
sendBehavior: SendBehavior; sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
} }
export interface Settings extends AppSettings { export interface Settings extends AppSettings {
@@ -32,6 +36,7 @@ export interface Settings extends AppSettings {
export const DEFAULT_CLIENT_SETTINGS: AppSettings = { export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto", theme: "auto",
sendBehavior: "interrupt", sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
}; };
export const DEFAULT_APP_SETTINGS: Settings = { export const DEFAULT_APP_SETTINGS: Settings = {
@@ -114,6 +119,9 @@ export function useSettings(): UseSettingsReturn {
if (updates.sendBehavior !== undefined) { if (updates.sendBehavior !== undefined) {
appUpdates.sendBehavior = updates.sendBehavior; appUpdates.sendBehavior = updates.sendBehavior;
} }
if (updates.serviceUrlBehavior !== undefined) {
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
}
const promises: Promise<void>[] = []; const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) { if (Object.keys(appUpdates).length > 0) {
@@ -162,6 +170,15 @@ export function useSettings(): UseSettingsReturn {
}; };
} }
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
const current =
appQueryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
(await loadAppSettingsFromStorage());
const next = { ...current, ...updates };
appQueryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
}
export async function loadAppSettingsFromStorage(): Promise<AppSettings> { export async function loadAppSettingsFromStorage(): Promise<AppSettings> {
try { try {
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
@@ -223,6 +240,12 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") { if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
result.sendBehavior = stored.sendBehavior; result.sendBehavior = stored.sendBehavior;
} }
if (
typeof stored.serviceUrlBehavior === "string" &&
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior as ServiceUrlBehavior)
) {
result.serviceUrlBehavior = stored.serviceUrlBehavior as ServiceUrlBehavior;
}
return result; return result;
} }

View File

@@ -39,6 +39,7 @@ import {
useSettings, useSettings,
type AppSettings, type AppSettings,
type SendBehavior, type SendBehavior,
type ServiceUrlBehavior,
type Settings as EffectiveSettings, type Settings as EffectiveSettings,
} from "@/hooks/use-settings"; } from "@/hooks/use-settings";
import { THEME_SWATCHES } from "@/styles/theme"; import { THEME_SWATCHES } from "@/styles/theme";
@@ -190,14 +191,24 @@ const RELEASE_CHANNEL_OPTIONS = [
{ value: "beta" as const, label: "Beta" }, { value: "beta" as const, label: "Beta" },
]; ];
const SERVICE_URL_BEHAVIOR_LABELS: Record<ServiceUrlBehavior, string> = {
ask: "Ask",
"in-app": "In Paseo",
external: "External browser",
};
const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Section components // Section components
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface GeneralSectionProps { interface GeneralSectionProps {
settings: AppSettings; settings: AppSettings;
isDesktopApp: boolean;
handleThemeChange: (theme: AppSettings["theme"]) => void; handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void; handleSendBehaviorChange: (behavior: SendBehavior) => void;
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
} }
interface ThemeMenuItemProps { interface ThemeMenuItemProps {
@@ -229,10 +240,33 @@ function ThemeMenuItem({
); );
} }
interface ServiceUrlBehaviorMenuItemProps {
value: ServiceUrlBehavior;
selected: boolean;
onChange: (value: ServiceUrlBehavior) => void;
}
function ServiceUrlBehaviorMenuItem({
value,
selected,
onChange,
}: ServiceUrlBehaviorMenuItemProps) {
const handleSelect = useCallback(() => {
onChange(value);
}, [onChange, value]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{SERVICE_URL_BEHAVIOR_LABELS[value]}
</DropdownMenuItem>
);
}
function GeneralSection({ function GeneralSection({
settings, settings,
isDesktopApp,
handleThemeChange, handleThemeChange,
handleSendBehaviorChange, handleSendBehaviorChange,
handleServiceUrlBehaviorChange,
}: GeneralSectionProps) { }: GeneralSectionProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const iconSize = theme.iconSize.md; const iconSize = theme.iconSize.md;
@@ -290,6 +324,32 @@ function GeneralSection({
options={SEND_BEHAVIOR_OPTIONS} options={SEND_BEHAVIOR_OPTIONS}
/> />
</View> </View>
{isDesktopApp ? (
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Service URLs</Text>
<Text style={settingsStyles.rowHint}>Where to open URLs from running scripts</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger style={themeTriggerStyle}>
<Text style={styles.themeTriggerText}>
{SERVICE_URL_BEHAVIOR_LABELS[settings.serviceUrlBehavior]}
</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{SERVICE_URL_BEHAVIOR_VALUES.map((value) => (
<ServiceUrlBehaviorMenuItem
key={value}
value={value}
selected={settings.serviceUrlBehavior === value}
onChange={handleServiceUrlBehaviorChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
</View> </View>
</SettingsSection> </SettingsSection>
); );
@@ -768,6 +828,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[updateSettings], [updateSettings],
); );
const handleServiceUrlBehaviorChange = useCallback(
(behavior: ServiceUrlBehavior) => {
void updateSettings({ serviceUrlBehavior: behavior });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => { const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) { if (!voiceAudioEngine || isPlaybackTestRunning) {
return; return;
@@ -949,8 +1016,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return ( return (
<GeneralSection <GeneralSection
settings={settings} settings={settings}
isDesktopApp={isDesktopApp}
handleThemeChange={handleThemeChange} handleThemeChange={handleThemeChange}
handleSendBehaviorChange={handleSendBehaviorChange} handleSendBehaviorChange={handleSendBehaviorChange}
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
/> />
); );
case "shortcuts": case "shortcuts":

View File

@@ -44,7 +44,12 @@ describe("workspace bulk close helpers", () => {
expect(groups).toEqual({ expect(groups).toEqual({
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }], agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }], terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
otherTabs: [{ tabId: "file_/repo/README.md" }], otherTabs: [
{
tabId: "file_/repo/README.md",
target: { kind: "file", path: "/repo/README.md" },
},
],
}); });
}); });
@@ -119,7 +124,7 @@ describe("workspace bulk close helpers", () => {
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } }, { tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } }, { tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
{ tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } }, { tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } },
{ tabId: "file_/repo/README.md" }, { tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
]); ]);
}); });
@@ -158,7 +163,7 @@ describe("workspace bulk close helpers", () => {
expect(cleanupCalls).toEqual([ expect(cleanupCalls).toEqual([
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } }, { tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } }, { tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
{ tabId: "file_/repo/README.md" }, { tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
]); ]);
}); });
}); });

View File

@@ -4,7 +4,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
export interface BulkClosableTabGroups { export interface BulkClosableTabGroups {
agentTabs: Array<{ tabId: string; agentId: string }>; agentTabs: Array<{ tabId: string; agentId: string }>;
terminalTabs: Array<{ tabId: string; terminalId: string }>; terminalTabs: Array<{ tabId: string; terminalId: string }>;
otherTabs: Array<{ tabId: string }>; otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
} }
interface CloseWorkspaceTabWithCleanupInput { interface CloseWorkspaceTabWithCleanupInput {
@@ -37,7 +37,7 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.target.terminalId }); groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.target.terminalId });
continue; continue;
} }
groups.otherTabs.push({ tabId: tab.tabId }); groups.otherTabs.push({ tabId: tab.tabId, target: tab.target });
} }
return groups; return groups;
@@ -103,9 +103,9 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
}); });
} }
for (const { tabId } of groups.otherTabs) { for (const { tabId, target } of groups.otherTabs) {
void closeTab(tabId, async () => { void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId }); closeWorkspaceTabWithCleanup({ tabId, target });
}); });
} }
} }

View File

@@ -97,7 +97,8 @@ import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { createWorkspaceBrowser } from "@/stores/browser-store"; import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store";
import { getDesktopHost } from "@/desktop/host";
import { buildProviderCommand } from "@/utils/provider-command-templates"; import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { import {
@@ -1785,6 +1786,11 @@ function WorkspaceScreenContent({
unpinWorkspaceAgent(persistenceKey, input.target.agentId); unpinWorkspaceAgent(persistenceKey, input.target.agentId);
hideWorkspaceAgent(persistenceKey, input.target.agentId); hideWorkspaceAgent(persistenceKey, input.target.agentId);
} }
if (input.target?.kind === "browser") {
const { browserId } = input.target;
useBrowserStore.getState().removeBrowser(browserId);
void getDesktopHost()?.browser?.clearPartition?.(browserId);
}
closeWorkspaceTab(persistenceKey, normalizedTabId); closeWorkspaceTab(persistenceKey, normalizedTabId);
}, },
[closeWorkspaceTab, hideWorkspaceAgent, persistenceKey, unpinWorkspaceAgent], [closeWorkspaceTab, hideWorkspaceAgent, persistenceKey, unpinWorkspaceAgent],
@@ -2160,6 +2166,17 @@ function WorkspaceScreenContent({
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey], [focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
); );
const handleOpenUrlInBrowserTab = useCallback(
(url: string) => {
if (!persistenceKey || !getIsElectron()) {
return;
}
const { browserId } = createWorkspaceBrowser({ initialUrl: url });
openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
},
[openWorkspaceTabFocused, persistenceKey],
);
const handleSelectSwitcherTab = useCallback( const handleSelectSwitcherTab = useCallback(
(key: string) => { (key: string) => {
navigateToTabId(key); navigateToTabId(key);
@@ -2271,11 +2288,14 @@ function WorkspaceScreenContent({
); );
const handleCloseDraftOrFileTab = useCallback( const handleCloseDraftOrFileTab = useCallback(
function handleCloseDraftOrFileTab(tabId: string) { function handleCloseDraftOrFileTab(input: {
setHoveredTabKey((current) => (current === tabId ? null : current)); tabId: string;
setHoveredCloseTabKey((current) => (current === tabId ? null : current)); target?: WorkspaceTabTarget | null;
}) {
setHoveredTabKey((current) => (current === input.tabId ? null : current));
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
if (persistenceKey) { if (persistenceKey) {
closeWorkspaceTabWithCleanup({ tabId }); closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
} }
}, },
[closeWorkspaceTabWithCleanup, persistenceKey], [closeWorkspaceTabWithCleanup, persistenceKey],
@@ -2295,7 +2315,7 @@ function WorkspaceScreenContent({
await handleCloseAgentTab({ tabId, agentId: tab.target.agentId }); await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
return; return;
} }
handleCloseDraftOrFileTab(tabId); handleCloseDraftOrFileTab({ tabId, target: tab.target });
}, },
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab], [allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
); );
@@ -2925,6 +2945,7 @@ function WorkspaceScreenContent({
liveTerminalIds={liveTerminalIds} liveTerminalIds={liveTerminalIds}
onScriptTerminalStarted={handleScriptTerminalStarted} onScriptTerminalStarted={handleScriptTerminalStarted}
onViewTerminal={handleViewScriptTerminal} onViewTerminal={handleViewScriptTerminal}
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
hideLabels={showCompactButtonLabels} hideLabels={showCompactButtonLabels}
/> />
) : null} ) : null}
@@ -3041,6 +3062,7 @@ function WorkspaceScreenContent({
liveTerminalIds, liveTerminalIds,
handleScriptTerminalStarted, handleScriptTerminalStarted,
handleViewScriptTerminal, handleViewScriptTerminal,
handleOpenUrlInBrowserTab,
showCompactButtonLabels, showCompactButtonLabels,
isGitCheckout, isGitCheckout,
handleToggleExplorer, handleToggleExplorer,

View File

@@ -12,10 +12,11 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
useDropdownMenuClose,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { openExternalUrl } from "@/utils/open-external-url"; import { openServiceUrl } from "@/utils/open-service-url";
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links"; import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -28,6 +29,7 @@ interface WorkspaceScriptsButtonProps {
liveTerminalIds?: readonly string[]; liveTerminalIds?: readonly string[];
onScriptTerminalStarted?: (terminalId: string) => void; onScriptTerminalStarted?: (terminalId: string) => void;
onViewTerminal?: (terminalId: string) => void; onViewTerminal?: (terminalId: string) => void;
onOpenUrlInBrowserTab?: (url: string) => void;
hideLabels?: boolean; hideLabels?: boolean;
} }
@@ -136,6 +138,7 @@ interface HostLinkProps {
label: string; label: string;
url: string | null; url: string | null;
scriptName: string; scriptName: string;
onOpenInBrowserTab?: (url: string) => void;
} }
interface HostLinkChildrenProps { interface HostLinkChildrenProps {
@@ -161,15 +164,18 @@ function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps):
); );
} }
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement { function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkProps): ReactElement {
const disabled = !url; const disabled = !url;
const closeMenu = useDropdownMenuClose();
const handlePress = useCallback( const handlePress = useCallback(
(event: GestureResponderEvent) => { (event: GestureResponderEvent) => {
event.stopPropagation(); event.stopPropagation();
if (url) void openExternalUrl(url); if (!url) return;
closeMenu();
void openServiceUrl(url, { openInApp: onOpenInBrowserTab });
}, },
[url], [url, onOpenInBrowserTab, closeMenu],
); );
const renderChildren = useCallback( const renderChildren = useCallback(
@@ -219,6 +225,7 @@ interface ScriptRowProps {
isStartPending: boolean; isStartPending: boolean;
onStartScript: (scriptName: string) => void; onStartScript: (scriptName: string) => void;
onViewTerminal?: (terminalId: string) => void; onViewTerminal?: (terminalId: string) => void;
onOpenUrlInBrowserTab?: (url: string) => void;
} }
function resolveScriptIconColorMapping(args: { function resolveScriptIconColorMapping(args: {
@@ -244,6 +251,7 @@ function ScriptRow({
isStartPending, isStartPending,
onStartScript, onStartScript,
onViewTerminal, onViewTerminal,
onOpenUrlInBrowserTab,
}: ScriptRowProps): ReactElement { }: ScriptRowProps): ReactElement {
const isRunning = script.lifecycle === "running"; const isRunning = script.lifecycle === "running";
const isService = (script.type ?? "service") === "service"; const isService = (script.type ?? "service") === "service";
@@ -340,6 +348,7 @@ function ScriptRow({
label={link.label} label={link.label}
url={link.url} url={link.url}
scriptName={script.scriptName} scriptName={script.scriptName}
onOpenInBrowserTab={onOpenUrlInBrowserTab}
/> />
))} ))}
</View> </View>
@@ -355,6 +364,7 @@ export function WorkspaceScriptsButton({
liveTerminalIds = [], liveTerminalIds = [],
onScriptTerminalStarted, onScriptTerminalStarted,
onViewTerminal, onViewTerminal,
onOpenUrlInBrowserTab,
hideLabels, hideLabels,
}: WorkspaceScriptsButtonProps): ReactElement | null { }: WorkspaceScriptsButtonProps): ReactElement | null {
const toast = useToast(); const toast = useToast();
@@ -438,6 +448,7 @@ export function WorkspaceScriptsButton({
isStartPending={startScriptMutation.isPending} isStartPending={startScriptMutation.isPending}
onStartScript={handleStartScript} onStartScript={handleStartScript}
onViewTerminal={onViewTerminal} onViewTerminal={onViewTerminal}
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
/> />
</Fragment> </Fragment>
))} ))}

View File

@@ -0,0 +1,51 @@
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import {
loadAppSettingsFromStorage,
persistAppSettings,
type ServiceUrlBehavior,
} from "@/hooks/use-settings";
import { openExternalUrl } from "@/utils/open-external-url";
export interface OpenServiceUrlOptions {
openInApp?: (url: string) => void;
}
export async function openServiceUrl(url: string, options?: OpenServiceUrlOptions): Promise<void> {
const openInApp = options?.openInApp;
if (!openInApp || !isElectronRuntime()) {
await openExternalUrl(url);
return;
}
const behavior = await resolveBehavior(url);
if (behavior === "in-app") {
openInApp(url);
return;
}
await openExternalUrl(url);
}
async function resolveBehavior(url: string): Promise<Exclude<ServiceUrlBehavior, "ask">> {
const settings = await loadAppSettingsFromStorage();
if (settings.serviceUrlBehavior === "in-app" || settings.serviceUrlBehavior === "external") {
return settings.serviceUrlBehavior;
}
const askWithCheckbox = getDesktopHost()?.dialog?.askWithCheckbox;
if (typeof askWithCheckbox !== "function") {
return "external";
}
const result = await askWithCheckbox(`Open ${url}?`, {
title: "Open service URL",
okLabel: "In Paseo",
cancelLabel: "External browser",
checkboxLabel: "Don't ask again",
});
const choice: Exclude<ServiceUrlBehavior, "ask"> = result.confirmed ? "in-app" : "external";
if (result.dontAskAgain) {
await persistAppSettings({ serviceUrlBehavior: choice });
}
return choice;
}

View File

@@ -7,6 +7,11 @@ interface AskOptions {
kind?: "info" | "warning" | "error"; kind?: "info" | "warning" | "error";
} }
interface AskWithCheckboxOptions extends AskOptions {
checkboxLabel: string;
checkboxChecked?: boolean;
}
interface OpenOptions { interface OpenOptions {
title?: string; title?: string;
defaultPath?: string; defaultPath?: string;
@@ -35,6 +40,27 @@ export function registerDialogHandlers(): void {
return result.response === 1; return result.response === 1;
}); });
ipcMain.handle(
"paseo:dialog:askWithCheckbox",
async (event, message: string, options: AskWithCheckboxOptions) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showMessageBox(win ?? BrowserWindow.getFocusedWindow()!, {
type: resolveDialogType(options.kind),
title: options.title ?? "Confirm",
message,
buttons: [options.cancelLabel ?? "Cancel", options.okLabel ?? "OK"],
defaultId: 1,
cancelId: 0,
checkboxLabel: options.checkboxLabel,
checkboxChecked: options.checkboxChecked ?? false,
});
return {
confirmed: result.response === 1,
dontAskAgain: result.checkboxChecked,
};
},
);
ipcMain.handle("paseo:dialog:open", async (event, options?: OpenOptions) => { ipcMain.handle("paseo:dialog:open", async (event, options?: OpenOptions) => {
const win = BrowserWindow.fromWebContents(event.sender); const win = BrowserWindow.fromWebContents(event.sender);
const properties: Electron.OpenDialogOptions["properties"] = []; const properties: Electron.OpenDialogOptions["properties"] = [];

View File

@@ -9,7 +9,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron"; import { app, BrowserWindow, ipcMain, nativeImage, net, protocol, session } from "electron";
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js"; import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
import { import {
parseCliPassthroughArgsFromArgv, parseCliPassthroughArgsFromArgv,
@@ -219,6 +219,14 @@ ipcMain.handle("paseo:browser:set-active-pane", (_event, browserId: unknown) =>
setActivePaseoBrowserPaneId(typeof browserId === "string" ? browserId : null); setActivePaseoBrowserPaneId(typeof browserId === "string" ? browserId : null);
}); });
ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknown) => {
if (typeof browserId !== "string" || browserId.trim().length === 0) {
return;
}
const partition = `persist:paseo-browser-${browserId}`;
await session.fromPartition(partition).clearStorageData();
});
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
]); ]);

View File

@@ -43,6 +43,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
dialog: { dialog: {
ask: (message: string, options?: Record<string, unknown>) => ask: (message: string, options?: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:dialog:ask", message, options), ipcRenderer.invoke("paseo:dialog:ask", message, options),
askWithCheckbox: (message: string, options: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:dialog:askWithCheckbox", message, options),
open: (options?: Record<string, unknown>) => ipcRenderer.invoke("paseo:dialog:open", options), open: (options?: Record<string, unknown>) => ipcRenderer.invoke("paseo:dialog:open", options),
}, },
notification: { notification: {
@@ -60,5 +62,7 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
browser: { browser: {
setActivePane: (browserId: string | null) => setActivePane: (browserId: string | null) =>
ipcRenderer.invoke("paseo:browser:set-active-pane", browserId), ipcRenderer.invoke("paseo:browser:set-active-pane", browserId),
clearPartition: (browserId: string) =>
ipcRenderer.invoke("paseo:browser:clear-partition", browserId),
}, },
}); });