diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 11030fcc5fa..4c5eed44e8f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -35,6 +35,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + openPullRequestLinksInApp: false, planModeEnabled: false, showSkillsInSlashMenu: false, providerModelPreferences: {}, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c0554926..ce5b4c111e5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -507,6 +507,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), + ...(settings.openPullRequestLinksInApp !== DEFAULT_UNIFIED_SETTINGS.openPullRequestLinksInApp + ? ["Open pull request links in T3 Code"] + : []), ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? ["Show skills in slash menu"] : []), @@ -572,6 +575,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.glassOpacity, settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, + settings.openPullRequestLinksInApp, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, @@ -652,6 +656,7 @@ export function useSettingsRestore(onRestored?: () => void) { timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, + openPullRequestLinksInApp: DEFAULT_UNIFIED_SETTINGS.openPullRequestLinksInApp, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -2081,6 +2086,33 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + openPullRequestLinksInApp: DEFAULT_UNIFIED_SETTINGS.openPullRequestLinksInApp, + }) + } + /> + ) : null + } + control={ + + updateSettings({ openPullRequestLinksInApp: Boolean(checked) }) + } + aria-label="Open pull request links in T3 Code" + /> + } + /> + { describe("shouldOpenPullRequestExternally", () => { it("uses the browser for command-click and control-click", () => { - expect(shouldOpenPullRequestExternally({ metaKey: true, ctrlKey: false })).toBe(true); - expect(shouldOpenPullRequestExternally({ metaKey: false, ctrlKey: true })).toBe(true); + expect(shouldOpenPullRequestExternally({ metaKey: true, ctrlKey: false }, true)).toBe(true); + expect(shouldOpenPullRequestExternally({ metaKey: false, ctrlKey: true }, true)).toBe(true); }); it("keeps an unmodified click in the pull request view", () => { - expect(shouldOpenPullRequestExternally({ metaKey: false, ctrlKey: false })).toBe(false); + expect(shouldOpenPullRequestExternally({ metaKey: false, ctrlKey: false }, true)).toBe(false); + }); + + it("sends every click to the browser once the in-app preference is off", () => { + expect(shouldOpenPullRequestExternally({ metaKey: false, ctrlKey: false }, false)).toBe(true); + expect(shouldOpenPullRequestExternally({ metaKey: true, ctrlKey: false }, false)).toBe(true); }); }); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 22ee2938a03..5008abb6767 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -11,6 +11,7 @@ import { type MouseEvent, useCallback } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { useClientSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; import { useRightPanelStore } from "../rightPanelStore"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; @@ -210,11 +211,16 @@ export function findProjectForChangeRequest( * the pull requests page: a reader following a link the agent wrote is reading the thread, and * should still be reading it afterwards. Any change request opens there, not only the thread's * own, since the panel is told which one to show. + * + * Settings → General turns this off for readers who want every pull request link in their + * browser, in which case nothing here claims a link at all. */ +/** Command-click (control-click off macOS) always means the browser; `openInApp` decides the rest. */ export function shouldOpenPullRequestExternally( event: Pick, "metaKey" | "ctrlKey">, + openInApp: boolean, ): boolean { - return event.metaKey || event.ctrlKey; + return !openInApp || event.metaKey || event.ctrlKey; } export function useOpenChangeRequestLink( @@ -228,12 +234,13 @@ export function useOpenChangeRequestLink( targetThreadRef?: ScopedThreadRef, ) => boolean { const navigate = useNavigate(); + const openInApp = useClientSettings((settings) => settings.openPullRequestLinksInApp); const allProjects = useProjects(); const serverConfigs = useServerConfigs(); const primaryEnvironmentId = usePrimaryEnvironmentId(); return useCallback( (event, targetUrl, targetThreadRef) => { - if (shouldOpenPullRequestExternally(event)) return false; + if (shouldOpenPullRequestExternally(event, openInApp)) return false; const resolvedThreadRef = targetThreadRef ?? threadRef; const parsed = parseChangeRequestUrl(targetUrl); if (parsed === null) return false; @@ -285,16 +292,17 @@ export function useOpenChangeRequestLink( }); return true; }, - [allProjects, navigate, primaryEnvironmentId, serverConfigs, threadRef], + [allProjects, navigate, openInApp, primaryEnvironmentId, serverConfigs, threadRef], ); } export function useOpenPrLink(threadRef?: ScopedThreadRef) { const openChangeRequest = useOpenChangeRequestLink(threadRef); + const openInApp = useClientSettings((settings) => settings.openPullRequestLinksInApp); return useCallback( (event: MouseEvent, prUrl: string, targetThreadRef?: ScopedThreadRef) => { event.stopPropagation(); - const openInBrowser = shouldOpenPullRequestExternally(event); + const openInBrowser = shouldOpenPullRequestExternally(event, openInApp); const isAnchor = event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.href.length > 0; // A real link already knows how to cmd/ctrl+click. Leave its default @@ -326,6 +334,6 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) { }); return false; }, - [openChangeRequest], + [openChangeRequest, openInApp], ); } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 916536bbe73..eb6c57338fb 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -43,6 +43,7 @@ T3 Code works with the platforms your team already uses: leaving the conversation - Open the review directly in your browser with one click - Command-click (Control-click on Windows and Linux) a pull request number in the sidebar to open it in your browser instead of in T3 Code +- Prefer your browser for everything? Turn off **Settings → General → Open pull request links in T3 Code**, and every pull request link opens there instead - Check out a teammate's branch to review code locally **Fix what you wrote, in place** diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 76bb032c866..01f472e45ba 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -221,6 +221,10 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // Where an unmodified click on a pull request link lands: the right-panel + // review beside the conversation, or the browser. Off makes every click + // behave the way command-click already does. + openPullRequestLinksInApp: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy plan mode. The composer's Build/Plan toggle was removed from the // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. @@ -938,6 +942,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + openPullRequestLinksInApp: Schema.optionalKey(Schema.Boolean), planModeEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),