diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 807e33af2..86aa0e739 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,4 +1,5 @@ import { + type ProjectId, GitActionFailure as GitActionFailureSchema, type GitActionFailure, type GitActionProgressEvent, @@ -84,14 +85,17 @@ import { invalidateGitQueries, } from "~/lib/gitReactQuery"; import { subscribeToGitPullRequestAction } from "~/lib/gitPullRequestAction"; +import { openGitHubUrl } from "~/lib/openGitHubUrl"; import { newCommandId, newMessageId, randomUUID } from "~/lib/utils"; -import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; +import { usePreviewStateStore } from "~/previewStateStore"; +import { resolvePathLinkTarget } from "~/terminal-links"; import { isWsRequestError } from "~/wsTransport"; interface GitActionsControlProps { gitCwd: string | null; activeThreadId: ThreadId | null; + activeProjectId: ProjectId | null; } interface PendingDefaultBranchAction { @@ -355,8 +359,13 @@ function GitSyncActionIcon() { return ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ + gitCwd, + activeThreadId, + activeProjectId, +}: GitActionsControlProps) { const { settings } = useAppSettings(); + const setPreviewOpen = usePreviewStateStore((state) => state.setProjectOpen); const openFileInViewer = useFileViewNavigation(); const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), @@ -575,15 +584,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [updateActiveProgressToast]); const openExistingPr = useCallback(async () => { - const api = readNativeApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Link opening is unavailable.", - data: threadToastData, - }); - return; - } const prUrl = openPullRequest?.url ?? null; if (!prUrl) { toastManager.add({ @@ -593,7 +593,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); return; } - void api.shell.openExternal(prUrl).catch((err) => { + void openGitHubUrl({ + url: prUrl, + projectId: activeProjectId, + threadId: activeThreadId, + setPreviewOpen, + }).catch((err) => { toastManager.add({ type: "error", title: "Unable to open PR link", @@ -601,7 +606,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions data: threadToastData, }); }); - }, [openPullRequest, threadToastData]); + }, [activeProjectId, activeThreadId, openPullRequest, setPreviewOpen, threadToastData]); const copyOpenPullRequestNumber = useCallback(() => { if (!openPullRequest) return; @@ -770,10 +775,20 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions actionProps: { children: formatOpenPullRequestLabel(prNumber), onClick: () => { - const api = readNativeApi(); - if (!api) return; closeResultToast(); - void api.shell.openExternal(prUrl); + void openGitHubUrl({ + url: prUrl, + projectId: activeProjectId, + threadId: activeThreadId, + setPreviewOpen, + }).catch((err) => { + toastManager.add({ + type: "error", + title: "Unable to open PR link", + description: err instanceof Error ? err.message : "An error occurred.", + data: threadToastData, + }); + }); }, }, } diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 6724a20ca..7d7644c61 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -11,7 +11,7 @@ import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor"; import { shortcutLabelsForCommand } from "~/keybindings"; import type { ClientMode } from "~/lib/clientMode"; import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; -import { ensureNativeApi } from "~/nativeApi"; +import { openGitHubUrl } from "~/lib/openGitHubUrl"; import type { PreviewDock } from "~/previewStateStore"; import type { ProjectScriptDraft } from "~/projectScriptDefaults"; import { EditableThreadTitle } from "../EditableThreadTitle"; @@ -19,6 +19,7 @@ import GitActionsControl from "../GitActionsControl"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Button } from "../ui/button"; import { Kbd } from "../ui/kbd"; +import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { HeaderPanelsMenu } from "./HeaderPanelsMenu"; @@ -58,7 +59,7 @@ interface ChatHeaderProps { export const ChatHeader = memo(function ChatHeader({ activeThreadId, activeThreadTitle, - activeProjectId: _activeProjectId, + activeProjectId, activeProjectName, activeProjectCwd, isLocalDraftThread, @@ -121,9 +122,22 @@ export const ChatHeader = memo(function ChatHeader({ const pullRequestShortcutLabels = shortcutLabelsForCommand(keybindings, "git.pullRequest"); const primaryPullRequestShortcutLabel = pullRequestShortcutLabels[0] ?? null; - const openPrLink = useCallback((url: string) => { - void ensureNativeApi().shell.openExternal(url); - }, []); + const openPrLink = useCallback( + (url: string) => { + void openGitHubUrl({ + url, + projectId: activeProjectId ?? null, + threadId: activeThreadId, + }).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open PR link", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, + [activeProjectId, activeThreadId], + ); return (
@@ -217,7 +231,11 @@ export const ChatHeader = memo(function ChatHeader({ )} {!isMobileCompanion && activeProjectName && ( - + )} {/* Overflow menu: all panel toggles consolidated */} {!isMobileCompanion && ( diff --git a/apps/web/src/components/sme/SmeChatWorkspace.tsx b/apps/web/src/components/sme/SmeChatWorkspace.tsx index 06e73bd9c..e5f11ae55 100644 --- a/apps/web/src/components/sme/SmeChatWorkspace.tsx +++ b/apps/web/src/components/sme/SmeChatWorkspace.tsx @@ -1,14 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { - ArrowUpIcon, - BookOpenIcon, - Settings2Icon, - SparklesIcon, - XIcon, -} from "lucide-react"; +import { ArrowUpIcon, BookOpenIcon, Settings2Icon, SparklesIcon, XIcon } from "lucide-react"; import type { SmeConversationId, SmeMessage, SmeMessageId } from "@okcode/contracts"; -import type { RegisteredRouter } from "@tanstack/react-router"; import { getProviderStartOptions, useAppSettings } from "~/appSettings"; import { ProviderHealthBanner } from "~/components/chat/ProviderHealthBanner"; @@ -36,7 +29,6 @@ export function SmeChatWorkspace({ onToggleKnowledge, knowledgePanelOpen, }: SmeChatWorkspaceProps) { - const navigate = useNavigate(); const { settings } = useAppSettings(); const providerOptions = useMemo(() => getProviderStartOptions(settings), [settings]); const conversations = useSmeStore((state) => state.conversations); diff --git a/apps/web/src/lib/openGitHubUrl.test.ts b/apps/web/src/lib/openGitHubUrl.test.ts new file mode 100644 index 000000000..048425267 --- /dev/null +++ b/apps/web/src/lib/openGitHubUrl.test.ts @@ -0,0 +1,76 @@ +import type { DesktopBridge, NativeApi } from "@okcode/contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { canOpenGitHubUrlInPreview, openGitHubUrl } from "./openGitHubUrl"; + +describe("openGitHubUrl", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("opens GitHub URLs in preview when desktop preview context is available", async () => { + const createTab = vi.fn().mockResolvedValue({ + tabId: "tab-1", + state: { tabs: [], activeTabId: null, visible: true }, + }); + const previewBridge = { createTab } as unknown as DesktopBridge["preview"]; + const setPreviewOpen = vi.fn(); + const openExternal = vi.fn().mockResolvedValue(undefined); + const nativeApi = { shell: { openExternal } } as unknown as NativeApi; + + const result = await openGitHubUrl({ + url: "https://github.com/OpenKnots/okcode/pull/42", + projectId: "project-1" as never, + threadId: "thread-1" as never, + previewBridge, + nativeApi, + setPreviewOpen, + }); + + expect(result).toBe("preview"); + expect(setPreviewOpen).toHaveBeenCalledWith("project-1", true); + expect(createTab).toHaveBeenCalledWith({ + url: "https://github.com/OpenKnots/okcode/pull/42", + threadId: "thread-1", + }); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("falls back to external open when preview is unavailable", async () => { + const openExternal = vi.fn().mockResolvedValue(undefined); + const nativeApi = { shell: { openExternal } } as unknown as NativeApi; + const setPreviewOpen = vi.fn(); + + const result = await openGitHubUrl({ + url: "https://github.com/OpenKnots/okcode/issues/42", + projectId: "project-1" as never, + threadId: null, + nativeApi, + setPreviewOpen, + }); + + expect(result).toBe("external"); + expect(setPreviewOpen).not.toHaveBeenCalled(); + expect(openExternal).toHaveBeenCalledWith("https://github.com/OpenKnots/okcode/issues/42"); + }); + + it("only treats GitHub http urls as preview eligible", () => { + expect( + canOpenGitHubUrlInPreview({ + url: "https://github.com/OpenKnots/okcode/pull/42", + projectId: "project-1" as never, + threadId: "thread-1" as never, + previewBridge: {} as never, + }), + ).toBe(true); + + expect( + canOpenGitHubUrlInPreview({ + url: "https://example.com/OpenKnots/okcode/pull/42", + projectId: "project-1" as never, + threadId: "thread-1" as never, + previewBridge: {} as never, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/lib/openGitHubUrl.ts b/apps/web/src/lib/openGitHubUrl.ts new file mode 100644 index 000000000..2431f008a --- /dev/null +++ b/apps/web/src/lib/openGitHubUrl.ts @@ -0,0 +1,60 @@ +import type { DesktopBridge, NativeApi, ProjectId, ThreadId } from "@okcode/contracts"; + +import { readDesktopPreviewBridge } from "~/desktopPreview"; +import { readNativeApi } from "~/nativeApi"; +import { usePreviewStateStore } from "~/previewStateStore"; + +export interface OpenGitHubUrlInput { + url: string; + projectId: ProjectId | null; + threadId: ThreadId | null; + nativeApi?: NativeApi | undefined; + previewBridge?: DesktopBridge["preview"] | null | undefined; + setPreviewOpen?: ((projectId: ProjectId, open: boolean) => void) | undefined; +} + +function isGitHubHttpUrl(url: string): boolean { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + (hostname === "github.com" || hostname === "www.github.com") + ); + } catch { + return false; + } +} + +export function canOpenGitHubUrlInPreview(input: OpenGitHubUrlInput): boolean { + return ( + isGitHubHttpUrl(input.url) && + input.projectId !== null && + input.threadId !== null && + (input.previewBridge ?? readDesktopPreviewBridge()) !== null + ); +} + +export async function openGitHubUrl(input: OpenGitHubUrlInput): Promise<"preview" | "external"> { + const previewBridge = input.previewBridge ?? readDesktopPreviewBridge(); + const setPreviewOpen = input.setPreviewOpen ?? usePreviewStateStore.getState().setProjectOpen; + + if ( + isGitHubHttpUrl(input.url) && + previewBridge !== null && + input.projectId !== null && + input.threadId !== null + ) { + setPreviewOpen(input.projectId, true); + await previewBridge.createTab({ url: input.url, threadId: input.threadId }); + return "preview"; + } + + const nativeApi = input.nativeApi ?? readNativeApi(); + if (!nativeApi) { + throw new Error("Link opening is unavailable."); + } + + await nativeApi.shell.openExternal(input.url); + return "external"; +}