From f9ce8b114fd9029dbe593f5fe5f864a398d200c8 Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:58:45 -0700 Subject: [PATCH] fix(graph): open external nodes in OS browser, not a new window (#2153) GitOrigin-RevId: 39a75e87e623ff0d5f1559ee72aea7d202d693bf --- .changeset/graph-external-link-os-browser.md | 5 +++ packages/app/src/components/GraphPanel.tsx | 3 +- packages/app/src/components/GraphView.tsx | 5 +-- packages/app/src/lib/external-link.test.ts | 32 ++++++++++++++++++++ packages/app/src/lib/external-link.ts | 18 +++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 .changeset/graph-external-link-os-browser.md create mode 100644 packages/app/src/lib/external-link.test.ts diff --git a/.changeset/graph-external-link-os-browser.md b/.changeset/graph-external-link-os-browser.md new file mode 100644 index 000000000..8d1c0ef97 --- /dev/null +++ b/.changeset/graph-external-link-os-browser.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge-app": patch +--- + +External URL nodes in the graph now open in your default OS browser on the desktop app, instead of a new in-app Open Knowledge window. Clicking an external node (or its "Open link" button in the graph side panel) routes through the desktop bridge's `openExternal`, so the link lands in your system browser the same way external links already open elsewhere in the editor. On the web build the behavior is unchanged (a new browser tab). Previously these three graph call sites used a raw `window.open`, which Electron turns into a new BrowserWindow rather than handing off to the OS. diff --git a/packages/app/src/components/GraphPanel.tsx b/packages/app/src/components/GraphPanel.tsx index 226ef252e..3067dd523 100644 --- a/packages/app/src/components/GraphPanel.tsx +++ b/packages/app/src/components/GraphPanel.tsx @@ -42,6 +42,7 @@ import { import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { hashFromDocName } from '@/lib/doc-hash'; +import { openExternalUrl } from '@/lib/external-link'; import { cn } from '@/lib/utils'; const FULLSCREEN_HUB_LIMIT = 50; @@ -390,7 +391,7 @@ export function GraphPanel({ activeDocName }: { activeDocName: string }) { actionLabel: t`Open link`, secondaryLabel: selectedNode.url, onAction: () => { - window.open(selectedNode.url, '_blank', 'noopener,noreferrer'); + openExternalUrl(selectedNode.url); setIsExpanded(false); }, }; diff --git a/packages/app/src/components/GraphView.tsx b/packages/app/src/components/GraphView.tsx index 5ec51b96d..3bab8a3d8 100644 --- a/packages/app/src/components/GraphView.tsx +++ b/packages/app/src/components/GraphView.tsx @@ -10,6 +10,7 @@ import ForceGraph2D, { import { usePageList } from '@/components/PageListContext'; import { hashFromDocName } from '@/lib/doc-hash'; import { subscribeToDocumentsChanged } from '@/lib/documents-events'; +import { openExternalUrl } from '@/lib/external-link'; import { cn } from '@/lib/utils'; import { clusterColor } from './graph-colors'; import { @@ -413,7 +414,7 @@ function applyGraphNodeClick({ const action = resolveGraphNodeClickAction(node, docClickBehavior); if (action.kind === 'external') { - window.open(action.url, '_blank', 'noopener,noreferrer'); + openExternalUrl(action.url); return; } @@ -1169,7 +1170,7 @@ export function GraphView({ showPointerCursor={(obj) => Boolean(obj && 'kind' in obj)} onNodeClick={(node: NodeObject) => { if (node.kind === 'external') { - window.open(node.url, '_blank', 'noopener,noreferrer'); + openExternalUrl(node.url); return; } if (node.docName) { diff --git a/packages/app/src/lib/external-link.test.ts b/packages/app/src/lib/external-link.test.ts new file mode 100644 index 000000000..b657fc3bc --- /dev/null +++ b/packages/app/src/lib/external-link.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { openExternalUrl } from './external-link.ts'; + +describe('openExternalUrl — Electron host', () => { + test('routes through okDesktop.shell.openExternal and does NOT open a new window', () => { + const openExternal = mock(async () => {}); + const openWindow = mock(() => null); + openExternalUrl('https://youtube.com/watch?v=abc', { + okDesktop: { shell: { openExternal } }, + openWindow, + }); + expect(openExternal).toHaveBeenCalledTimes(1); + expect(openExternal).toHaveBeenCalledWith('https://youtube.com/watch?v=abc'); + expect(openWindow).not.toHaveBeenCalled(); + }); +}); + +describe('openExternalUrl — web host (no bridge)', () => { + test('falls back to window.open with the new-tab + noopener features', () => { + const openWindow = mock(() => null); + openExternalUrl('https://example.com', { okDesktop: undefined, openWindow }); + expect(openWindow).toHaveBeenCalledTimes(1); + expect(openWindow).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer'); + }); + + test('falls back to window.open when the bridge has no openExternal', () => { + const openWindow = mock(() => null); + openExternalUrl('https://example.com', { okDesktop: { shell: {} }, openWindow }); + expect(openWindow).toHaveBeenCalledTimes(1); + expect(openWindow).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer'); + }); +}); diff --git a/packages/app/src/lib/external-link.ts b/packages/app/src/lib/external-link.ts index a7475e600..7f67b2a7c 100644 --- a/packages/app/src/lib/external-link.ts +++ b/packages/app/src/lib/external-link.ts @@ -4,3 +4,21 @@ export function dispatchExternalLinkClick(e: { preventDefault: () => void }, url e.preventDefault(); void openExternal(url); } + +interface OpenExternalUrlDeps { + readonly okDesktop?: { shell?: { openExternal?: (url: string) => Promise } }; + readonly openWindow?: (url: string, target: string, features: string) => unknown; +} + +export function openExternalUrl(url: string, deps: OpenExternalUrlDeps = {}): void { + const globalBridge = typeof window !== 'undefined' ? window.okDesktop : undefined; + const okDesktop = 'okDesktop' in deps ? deps.okDesktop : globalBridge; + const openExternal = okDesktop?.shell?.openExternal; + if (openExternal) { + void openExternal(url); + return; + } + const globalOpen = typeof window !== 'undefined' ? window.open.bind(window) : undefined; + const openWindow = deps.openWindow ?? globalOpen; + openWindow?.(url, '_blank', 'noopener,noreferrer'); +}