Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/app/src/utils/notification-click.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { handleNotificationClick, setNavigate } from "./notification-click"

describe("notification click", () => {
afterEach(() => {
setNavigate(undefined as any)
setNavigate(undefined)
})

test("navigates via registered navigate function", () => {
Expand All @@ -20,8 +20,11 @@ describe("notification click", () => {
expect(calls).toEqual([])
})

test("falls back to location.assign without registered navigate", () => {
test("queues navigation until a navigate function is registered", () => {
const calls: string[] = []
handleNotificationClick("/abc/session/123")
// falls back to window.location.assign — no error thrown
expect(calls).toEqual([])
setNavigate((href) => calls.push(href))
expect(calls).toEqual(["/abc/session/123"])
})
})
8 changes: 5 additions & 3 deletions packages/app/src/utils/notification-click.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
let nav: ((href: string) => void) | undefined
const pending: string[] = []

export const setNavigate = (fn: (href: string) => void) => {
export const setNavigate = (fn: ((href: string) => void) | undefined) => {
nav = fn
if (!nav) return
pending.splice(0).forEach(nav)
}

export const handleNotificationClick = (href?: string) => {
window.focus()
if (!href) return
if (nav) return nav(href)
console.warn("notification-click: navigate function not set, falling back to window.location.assign")
window.location.assign(href)
pending.push(href)
}
26 changes: 26 additions & 0 deletions packages/desktop/src/main/external-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { resolveExternalTarget } from "./external-target"

describe("external targets", () => {
test("opens web URLs externally", () => {
expect(resolveExternalTarget("https://example.com/a?b=c")).toEqual({
type: "url",
value: "https://example.com/a?b=c",
})
expect(resolveExternalTarget("http://example.com")).toEqual({ type: "url", value: "http://example.com/" })
})

test("opens local file URLs as paths", () => {
const url = pathToFileURL(resolve("example.html"))
expect(resolveExternalTarget(url.href)).toEqual({ type: "path", value: fileURLToPath(url) })
})

test("rejects remote files and unsupported protocols", () => {
expect(resolveExternalTarget("file://example.com/share/index.html")).toBeUndefined()
expect(resolveExternalTarget("javascript:alert(1)")).toBeUndefined()
expect(resolveExternalTarget("data:text/html,hello")).toBeUndefined()
expect(resolveExternalTarget("not a url")).toBeUndefined()
})
})
16 changes: 16 additions & 0 deletions packages/desktop/src/main/external-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { fileURLToPath } from "node:url"

type ExternalTarget = { type: "url"; value: string } | { type: "path"; value: string }

export function resolveExternalTarget(value: string): ExternalTarget | undefined {
if (!URL.canParse(value)) return undefined
const url = new URL(value)
if (url.protocol === "http:" || url.protocol === "https:") return { type: "url", value: url.href }
if (url.protocol !== "file:" || (url.hostname && url.hostname !== "localhost")) return undefined

try {
return { type: "path", value: fileURLToPath(url) }
} catch {
return undefined
}
}
9 changes: 8 additions & 1 deletion packages/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
import { resolveExternalTarget } from "./external-target"

const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
Expand Down Expand Up @@ -178,7 +179,13 @@ export function registerIpcHandlers(deps: Deps) {
)

ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
void shell.openExternal(url)
const target = resolveExternalTarget(url)
if (!target) return
if (target.type === "url") {
void shell.openExternal(target.value)
return
}
void shell.openPath(target.value)
})

ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
Expand Down
25 changes: 24 additions & 1 deletion packages/desktop/src/main/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
import { randomUUID } from "node:crypto"
import { rmSync } from "node:fs"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol, shell } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
Expand All @@ -14,6 +14,7 @@ import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
import { createWindowRegistry } from "./window-registry"
import { safeWindowURL } from "./window-state"
import { resolveExternalTarget } from "./external-target"

const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer")
Expand Down Expand Up @@ -204,6 +205,7 @@ export function createMainWindow(id: string = randomUUID()) {

allowRendererPermissions(win)
wireWindowRecovery(win, id)
wireNavigationPolicy(win)

win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
Expand All @@ -230,6 +232,27 @@ export function createMainWindow(id: string = randomUUID()) {
return win
}

function wireNavigationPolicy(win: BrowserWindow) {
const open = (value: string) => {
const target = resolveExternalTarget(value)
if (!target) return
if (target.type === "url") {
void shell.openExternal(target.value)
return
}
void shell.openPath(target.value)
}

win.webContents.setWindowOpenHandler(({ url }) => {
open(url)
return { action: "deny" }
})
win.webContents.on("will-navigate", (event, url) => {
event.preventDefault()
open(url)
})
}

function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id)
registry.register(id, win)
Expand Down
8 changes: 8 additions & 0 deletions packages/session-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
subtitle={taskSubtitle()}
href={taskHref()}
onSubtitleClick={(event) => {
if (!data.navigateToSession) return
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
const id = taskId()
if (!id) return
event.preventDefault()
data.navigateToSession(id)
}}
/>
)
}}
Expand Down
7 changes: 6 additions & 1 deletion packages/session-ui/src/components/tool-error-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "c
onOpenChange?: (open: boolean) => void
subtitle?: string
href?: string
onSubtitleClick?: (event: MouseEvent) => void
}

export function ToolErrorCard(props: ToolErrorCardProps) {
Expand All @@ -35,6 +36,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
"onOpenChange",
"subtitle",
"href",
"onSubtitleClick",
])
const setOpen = (value: boolean) => {
if (props.open === undefined) setState("open", value)
Expand Down Expand Up @@ -113,7 +115,10 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={split.href!}
onClick={(e) => e.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
split.onSubtitleClick?.(event)
}}
>
{subtitle()}
</a>
Expand Down
Loading