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
@@ -39,6 +39,7 @@ import {
useSettings,
type AppSettings,
type SendBehavior,
type ServiceUrlBehavior,
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
import { THEME_SWATCHES } from "@/styles/theme";
@@ -190,14 +191,24 @@ const RELEASE_CHANNEL_OPTIONS = [
{ 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
// ---------------------------------------------------------------------------
interface GeneralSectionProps {
settings: AppSettings;
isDesktopApp: boolean;
handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
}
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({
settings,
isDesktopApp,
handleThemeChange,
handleSendBehaviorChange,
handleServiceUrlBehaviorChange,
}: GeneralSectionProps) {
const { theme } = useUnistyles();
const iconSize = theme.iconSize.md;
@@ -290,6 +324,32 @@ function GeneralSection({
options={SEND_BEHAVIOR_OPTIONS}
/>
</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>
</SettingsSection>
);
@@ -768,6 +828,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[updateSettings],
);
const handleServiceUrlBehaviorChange = useCallback(
(behavior: ServiceUrlBehavior) => {
void updateSettings({ serviceUrlBehavior: behavior });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) {
return;
@@ -949,8 +1016,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return (
<GeneralSection
settings={settings}
isDesktopApp={isDesktopApp}
handleThemeChange={handleThemeChange}
handleSendBehaviorChange={handleSendBehaviorChange}
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
/>
);
case "shortcuts":
@@ -44,7 +44,12 @@ describe("workspace bulk close helpers", () => {
expect(groups).toEqual({
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
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: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
{ 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([
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
{ 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" } },
]);
});
});
@@ -4,7 +4,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
export interface BulkClosableTabGroups {
agentTabs: Array<{ tabId: string; agentId: string }>;
terminalTabs: Array<{ tabId: string; terminalId: string }>;
otherTabs: Array<{ tabId: string }>;
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
}
interface CloseWorkspaceTabWithCleanupInput {
@@ -37,7 +37,7 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.target.terminalId });
continue;
}
groups.otherTabs.push({ tabId: tab.tabId });
groups.otherTabs.push({ tabId: tab.tabId, target: tab.target });
}
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 () => {
closeWorkspaceTabWithCleanup({ tabId });
closeWorkspaceTabWithCleanup({ tabId, target });
});
}
}
@@ -97,7 +97,8 @@ import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
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 { generateDraftId } from "@/stores/draft-keys";
import {
@@ -1785,6 +1786,11 @@ function WorkspaceScreenContent({
unpinWorkspaceAgent(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, hideWorkspaceAgent, persistenceKey, unpinWorkspaceAgent],
@@ -2160,6 +2166,17 @@ function WorkspaceScreenContent({
[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(
(key: string) => {
navigateToTabId(key);
@@ -2271,11 +2288,14 @@ function WorkspaceScreenContent({
);
const handleCloseDraftOrFileTab = useCallback(
function handleCloseDraftOrFileTab(tabId: string) {
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
function handleCloseDraftOrFileTab(input: {
tabId: string;
target?: WorkspaceTabTarget | null;
}) {
setHoveredTabKey((current) => (current === input.tabId ? null : current));
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({ tabId });
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
}
},
[closeWorkspaceTabWithCleanup, persistenceKey],
@@ -2295,7 +2315,7 @@ function WorkspaceScreenContent({
await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
return;
}
handleCloseDraftOrFileTab(tabId);
handleCloseDraftOrFileTab({ tabId, target: tab.target });
},
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
);
@@ -2925,6 +2945,7 @@ function WorkspaceScreenContent({
liveTerminalIds={liveTerminalIds}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewTerminal={handleViewScriptTerminal}
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
hideLabels={showCompactButtonLabels}
/>
) : null}
@@ -3041,6 +3062,7 @@ function WorkspaceScreenContent({
liveTerminalIds,
handleScriptTerminalStarted,
handleViewScriptTerminal,
handleOpenUrlInBrowserTab,
showCompactButtonLabels,
isGitCheckout,
handleToggleExplorer,
@@ -12,10 +12,11 @@ import {
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
useDropdownMenuClose,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
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 type { Theme } from "@/styles/theme";
@@ -28,6 +29,7 @@ interface WorkspaceScriptsButtonProps {
liveTerminalIds?: readonly string[];
onScriptTerminalStarted?: (terminalId: string) => void;
onViewTerminal?: (terminalId: string) => void;
onOpenUrlInBrowserTab?: (url: string) => void;
hideLabels?: boolean;
}
@@ -136,6 +138,7 @@ interface HostLinkProps {
label: string;
url: string | null;
scriptName: string;
onOpenInBrowserTab?: (url: string) => void;
}
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 closeMenu = useDropdownMenuClose();
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
if (url) void openExternalUrl(url);
if (!url) return;
closeMenu();
void openServiceUrl(url, { openInApp: onOpenInBrowserTab });
},
[url],
[url, onOpenInBrowserTab, closeMenu],
);
const renderChildren = useCallback(
@@ -219,6 +225,7 @@ interface ScriptRowProps {
isStartPending: boolean;
onStartScript: (scriptName: string) => void;
onViewTerminal?: (terminalId: string) => void;
onOpenUrlInBrowserTab?: (url: string) => void;
}
function resolveScriptIconColorMapping(args: {
@@ -244,6 +251,7 @@ function ScriptRow({
isStartPending,
onStartScript,
onViewTerminal,
onOpenUrlInBrowserTab,
}: ScriptRowProps): ReactElement {
const isRunning = script.lifecycle === "running";
const isService = (script.type ?? "service") === "service";
@@ -340,6 +348,7 @@ function ScriptRow({
label={link.label}
url={link.url}
scriptName={script.scriptName}
onOpenInBrowserTab={onOpenUrlInBrowserTab}
/>
))}
</View>
@@ -355,6 +364,7 @@ export function WorkspaceScriptsButton({
liveTerminalIds = [],
onScriptTerminalStarted,
onViewTerminal,
onOpenUrlInBrowserTab,
hideLabels,
}: WorkspaceScriptsButtonProps): ReactElement | null {
const toast = useToast();
@@ -438,6 +448,7 @@ export function WorkspaceScriptsButton({
isStartPending={startScriptMutation.isPending}
onStartScript={handleStartScript}
onViewTerminal={onViewTerminal}
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
/>
</Fragment>
))}