authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 21:56:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 21:56:48-07:00
log18062da9425909a0a92bce0b692c9de6fbba56ee
treed929687ab4ae98a9ced52d81831379b56b9af154
parentd42254dfb462e4ab23a42d4bef53b1ce00a72396
signature Signed by PGP key B5690EEEBB952194

feat(web): choose whether links open in the default browser or in T3 Code (#9339)


24 files changed, 509 insertions(+), 168 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -18,6 +18,7 @@ const clientSettings: ClientSettings = {...@@ -18,6 +18,7 @@ const clientSettings: ClientSettings = {
18 browserDefaultZoomFactor: 1.25,18 browserDefaultZoomFactor: 1.25,
19 browserDefaultAppearance: "dark",19 browserDefaultAppearance: "dark",
20 browserRecordingFrameRate: 60,20 browserRecordingFrameRate: 60,
21 browserLinkTarget: "app",
21 browserAutoShowFloatingPreview: false,22 browserAutoShowFloatingPreview: false,
22 browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],23 browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
23 browserDefaultProfileId: "work",24 browserDefaultProfileId: "work",
apps/web/src/browser/browserLinkTarget.test.ts created+69
...@@ -0,0 +1,69 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveLinkTarget } from "./browserLinkTarget";
4
5const click = { metaKey: false, ctrlKey: false };
6
7describe("resolveLinkTarget", () => {
8 it("keeps the system browser unless the user asked for in-app", () => {
9 expect(
10 resolveLinkTarget({
11 url: "https://example.com/",
12 event: click,
13 preference: "system",
14 canOpenInApp: true,
15 }),
16 ).toBe("system");
17 });
18
19 it("opens in-app when asked and the runtime can", () => {
20 expect(
21 resolveLinkTarget({
22 url: "https://example.com/",
23 event: click,
24 preference: "app",
25 canOpenInApp: true,
26 }),
27 ).toBe("app");
28 });
29
30 it("falls back to the system browser where there is no in-app browser", () => {
31 // The hosted web app and mobile have nowhere to open a tab, so the
32 // preference cannot be honoured there and the link still has to open.
33 expect(
34 resolveLinkTarget({
35 url: "https://example.com/",
36 event: click,
37 preference: "app",
38 canOpenInApp: false,
39 }),
40 ).toBe("system");
41 });
42
43 it("treats a modifier click as the way out of the in-app default", () => {
44 expect(
45 resolveLinkTarget({
46 url: "https://example.com/",
47 event: { metaKey: true, ctrlKey: false },
48 preference: "app",
49 canOpenInApp: true,
50 }),
51 ).toBe("system");
52 expect(
53 resolveLinkTarget({
54 url: "https://example.com/",
55 event: { metaKey: false, ctrlKey: true },
56 preference: "app",
57 canOpenInApp: true,
58 }),
59 ).toBe("system");
60 });
61
62 it("leaves non-web schemes to the shell", () => {
63 for (const url of ["mailto:someone@example.com", "vscode://file/x", "not a url"]) {
64 expect(resolveLinkTarget({ url, event: click, preference: "app", canOpenInApp: true })).toBe(
65 "system",
66 );
67 }
68 });
69});
apps/web/src/browser/browserLinkTarget.ts created+67
...@@ -0,0 +1,67 @@
1/**
2 * Where a link clicked inside a thread should open.
3 *
4 * Settings → Integrations → Browser lets the user choose between the OS
5 * default browser and a tab in the in-app browser. This module turns that
6 * preference plus the click itself into one answer, so chat markdown and the
7 * terminal drawer make the same decision and offer the same escape hatch.
8 *
9 * @module browserLinkTarget
10 */
11import type { BrowserLinkTarget } from "@t3tools/contracts";
12
13import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";
14import { isPreviewSupportedInRuntime } from "~/previewStateStore";
15
16export interface ResolveLinkTargetInput {
17 readonly url: string;
18 /** Cmd/Ctrl-click always goes to the system browser, whatever the default. */
19 readonly event: { readonly metaKey: boolean; readonly ctrlKey: boolean };
20 readonly preference: BrowserLinkTarget;
21 /** Whether this client has an in-app browser and a thread to open it beside. */
22 readonly canOpenInApp: boolean;
23}
24
25/**
26 * The target a click resolves to. "app" only comes back when the preference
27 * asks for it, the runtime can honour it, the URL is one the in-app browser
28 * can load, and the click carried no modifier — the modifier is the one-gesture
29 * way out when the default is in-app, mirroring how change-request links
30 * already treat it.
31 */
32export function resolveLinkTarget(input: ResolveLinkTargetInput): BrowserLinkTarget {
33 if (input.event.metaKey || input.event.ctrlKey) return "system";
34 if (input.preference !== "app") return "system";
35 if (!input.canOpenInApp) return "system";
36 if (!isWebUrl(input.url)) return "system";
37 return "app";
38}
39
40/**
41 * Only http(s) can load in the in-app browser. Anything else — mailto:,
42 * vscode://, a bare fragment — belongs to the shell whatever the preference.
43 */
44export function isWebUrl(url: string): boolean {
45 try {
46 const { protocol } = new URL(url);
47 return protocol === "http:" || protocol === "https:";
48 } catch {
49 return false;
50 }
51}
52
53/**
54 * The configured default, once client settings have actually loaded. Before
55 * hydration the snapshot is the schema default ("system"), so a link clicked
56 * in the first moments after launch would ignore a persisted "app" — opening
57 * is asynchronous anyway, so waiting costs nothing the user can see.
58 */
59export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkTarget> {
60 await ensureClientSettingsHydrated();
61 return getClientSettings().browserLinkTarget;
62}
63
64/** Whether the in-app target is available at all in this client. */
65export function canOpenLinksInApp(hasThread: boolean): boolean {
66 return hasThread && isPreviewSupportedInRuntime();
67}
apps/web/src/browser/useOpenLink.ts created+63
...@@ -0,0 +1,63 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
3import { useCallback } from "react";
4
5import { recordVisitForThread } from "~/browserHistoryStore";
6import { readLocalApi } from "~/localApi";
7import { previewEnvironment } from "~/state/preview";
8import { useAtomCommand } from "~/state/use-atom-command";
9
10import {
11 canOpenLinksInApp,
12 resolveBrowserLinkTargetPreference,
13 resolveLinkTarget,
14} from "./browserLinkTarget";
15import { openUrlInPreview } from "./openFileInPreview";
16
17const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const;
18
19/**
20 * Opens a URL where the "Open links in" setting says, for buttons that sit
21 * beside a thread but are not markdown anchors: CI check details, a pull
22 * request that has no project to open in the panel. Without a thread there is
23 * nowhere to put an in-app tab, so the link goes to the system browser.
24 *
25 * An in-app open that fails falls back to the system browser rather than
26 * dropping the click: the user asked for the link, and the setting only says
27 * where it should go first. The returned promise rejects only when that
28 * fallback fails too, the same way `shell.openExternal` does.
29 */
30export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): (
31 url: string,
32 options?: {
33 readonly event?: { readonly metaKey: boolean; readonly ctrlKey: boolean };
34 /** Thread to open beside when it is not the hook's own, e.g. a sidebar row's. */
35 readonly threadRef?: ScopedThreadRef | undefined;
36 },
37) => Promise<void> {
38 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
39 return useCallback(
40 async (url, options = {}) => {
41 const targetThreadRef = options.threadRef ?? threadRef;
42 const target = resolveLinkTarget({
43 url,
44 event: options.event ?? NO_MODIFIER,
45 preference: await resolveBrowserLinkTargetPreference(),
46 canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)),
47 });
48 if (target === "app" && targetThreadRef) {
49 const result = await openUrlInPreview({ threadRef: targetThreadRef, url, openPreview });
50 if (isAtomCommandInterrupted(result)) return;
51 if (result._tag === "Success") {
52 recordVisitForThread(targetThreadRef, url);
53 return;
54 }
55 console.error(result.cause);
56 }
57 const api = readLocalApi();
58 if (!api) throw new Error("Link opening is unavailable.");
59 await api.shell.openExternal(url);
60 },
61 [openPreview, threadRef],
62 );
63}
apps/web/src/components/ChatMarkdown.tsx+36-4
...@@ -119,7 +119,7 @@ import { LRUCache } from "../lib/lruCache";...@@ -119,7 +119,7 @@ import { LRUCache } from "../lib/lruCache";
119import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";119import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
120import { RenderErrorBoundary } from "./RenderErrorBoundary";120import { RenderErrorBoundary } from "./RenderErrorBoundary";
121import { useTheme } from "../hooks/useTheme";121import { useTheme } from "../hooks/useTheme";
122import { getClientSettings } from "../hooks/useSettings";122import { getClientSettings, useClientSettings } from "../hooks/useSettings";
123import {123import {
124 chatMarkdownClipboardPayload,124 chatMarkdownClipboardPayload,
125 serializeTableElementToCsv,125 serializeTableElementToCsv,
...@@ -173,6 +173,7 @@ import {...@@ -173,6 +173,7 @@ import {
173 openUrlInPreview,173 openUrlInPreview,
174 BrowserPreviewUnavailableError,174 BrowserPreviewUnavailableError,
175} from "../browser/openFileInPreview";175} from "../browser/openFileInPreview";
176import { resolveLinkTarget } from "../browser/browserLinkTarget";
176177
177interface ChatMarkdownProps {178interface ChatMarkdownProps {
178 text: string;179 text: string;
...@@ -2120,6 +2121,10 @@ function ChatMarkdown({...@@ -2120,6 +2121,10 @@ function ChatMarkdown({
2120 event.clipboardData.setData("text/html", payload.html);2121 event.clipboardData.setData("text/html", payload.html);
2121 }, []);2122 }, []);
2122 const openChangeRequestLink = useOpenChangeRequestLink(threadRef);2123 const openChangeRequestLink = useOpenChangeRequestLink(threadRef);
2124 // Subscribed rather than read at click time: the anchor has to decide
2125 // synchronously whether to intercept its `_blank`, and a subscription is what
2126 // makes a persisted "app" apply once settings hydrate after launch.
2127 const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget);
2123 const resolveThreadPullRequest = useCallback(2128 const resolveThreadPullRequest = useCallback(
2124 (href: string): ThreadLinkedPullRequest | null => {2129 (href: string): ThreadLinkedPullRequest | null => {
2125 if (2130 if (
...@@ -2472,9 +2477,35 @@ function ChatMarkdown({...@@ -2472,9 +2477,35 @@ function ChatMarkdown({
2472 }2477 }
2473 // A link to a change request in a workspace project opens beside the2478 // A link to a change request in a workspace project opens beside the
2474 // conversation instead of in a browser: it is the thing being talked about, and2479 // conversation instead of in a browser: it is the thing being talked about, and
2475 // the panel it opens offers the browser as one of its actions. Anything else is2480 // the panel it opens offers the browser as one of its actions.
2476 // an ordinary link and keeps the `_blank` the shell already handles.2481 if (!href || openChangeRequestLink(event, href)) return;
2477 if (href) openChangeRequestLink(event, href);2482 // Anything else follows the "Open links in" setting. The system browser
2483 // keeps the `_blank` the shell already handles; the in-app browser needs
2484 // the click intercepted here. A modifier click is the way out of the
2485 // in-app default, so it is left to the shell too.
2486 if (
2487 event.defaultPrevented ||
2488 resolveLinkTarget({
2489 url: href,
2490 event,
2491 preference: linkTargetPreference,
2492 canOpenInApp: canOpenInPreview,
2493 }) !== "app"
2494 ) {
2495 return;
2496 }
2497 event.preventDefault();
2498 event.stopPropagation();
2499 // The click was taken from the shell, so an in-app open that fails
2500 // hands the link to the system browser instead of dropping it.
2501 void openExternalLinkInPreview(href).then((result) => {
2502 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
2503 reportMarkdownActionFailure(
2504 { operation: "open-link-in-preview", target: href },
2505 result.cause,
2506 );
2507 void readLocalApi()?.shell.openExternal(href);
2508 });
2478 }}2509 }}
2479 onContextMenu={(event) => {2510 onContextMenu={(event) => {
2480 if (!href || !faviconHost) return;2511 if (!href || !faviconHost) return;
...@@ -2711,6 +2742,7 @@ function ChatMarkdown({...@@ -2711,6 +2742,7 @@ function ChatMarkdown({
2711 inlineCodeFileLinkMetaByText,2742 inlineCodeFileLinkMetaByText,
2712 imageBaseDir,2743 imageBaseDir,
2713 isStreaming,2744 isStreaming,
2745 linkTargetPreference,
2714 markdownFileLinkMetaByHref,2746 markdownFileLinkMetaByHref,
2715 onTaskListChange,2747 onTaskListChange,
2716 onUseArtifactTemplate,2748 onUseArtifactTemplate,
apps/web/src/components/ChatView.tsx+1
...@@ -7323,6 +7323,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7323,6 +7323,7 @@ function ChatViewContent(props: ChatViewProps) {
7323 <PullRequestDetailPanel7323 <PullRequestDetailPanel
7324 key={`${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}7324 key={`${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
7325 environmentId={activeThread.environmentId}7325 environmentId={activeThread.environmentId}
7326 threadRef={activeThreadRef}
7326 reference={{7327 reference={{
7327 projectId: renderedRightPanelSurface.projectId as ProjectId,7328 projectId: renderedRightPanelSurface.projectId as ProjectId,
7328 repository: renderedRightPanelSurface.repository,7329 repository: renderedRightPanelSurface.repository,
apps/web/src/components/GitActionsControl.tsx+10-18
...@@ -32,7 +32,6 @@ import {...@@ -32,7 +32,6 @@ import {
32 ChevronDownIcon,32 ChevronDownIcon,
33 CloudDownloadIcon,33 CloudDownloadIcon,
34 CloudUploadIcon,34 CloudUploadIcon,
35 ExternalLinkIcon,
36 GitBranchPlusIcon,35 GitBranchPlusIcon,
37 GitCommitIcon,36 GitCommitIcon,
38 InfoIcon,37 InfoIcon,
...@@ -97,9 +96,9 @@ import { vcsEnvironment } from "~/state/vcs";...@@ -97,9 +96,9 @@ import { vcsEnvironment } from "~/state/vcs";
97import { randomUUID } from "~/lib/utils";96import { randomUUID } from "~/lib/utils";
98import { resolvePathLinkTarget } from "~/terminal-links";97import { resolvePathLinkTarget } from "~/terminal-links";
99import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";98import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
100import { readLocalApi } from "~/localApi";
101import { getSourceControlPresentation } from "~/sourceControlPresentation";99import { getSourceControlPresentation } from "~/sourceControlPresentation";
102import { openPullRequestLink, useOpenPrLink } from "~/lib/openPullRequestLink";100import { useOpenLink } from "~/browser/useOpenLink";
101import { useOpenPrLink } from "~/lib/openPullRequestLink";
103102
104interface GitActionsControlProps {103interface GitActionsControlProps {
105 gitCwd: string | null;104 gitCwd: string | null;
...@@ -384,10 +383,13 @@ interface PublishRepositoryDialogProps {...@@ -384,10 +383,13 @@ interface PublishRepositoryDialogProps {
384 readonly open: boolean;383 readonly open: boolean;
385 readonly onOpenChange: (open: boolean) => void;384 readonly onOpenChange: (open: boolean) => void;
386 readonly environmentId: ScopedThreadRef["environmentId"] | null;385 readonly environmentId: ScopedThreadRef["environmentId"] | null;
386 /** Thread the dialog was opened from, so the new repository can open beside it. */
387 readonly threadRef: ScopedThreadRef | null;
387 readonly gitCwd: string;388 readonly gitCwd: string;
388}389}
389390
390function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {391function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
392 const openLink = useOpenLink(props.threadRef);
391 const navigate = useNavigate();393 const navigate = useNavigate();
392 const sourceControlDiscovery = useEnvironmentQuery(394 const sourceControlDiscovery = useEnvironmentQuery(
393 props.environmentId === null395 props.environmentId === null
...@@ -911,12 +913,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -911,12 +913,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
911 size="sm"913 size="sm"
912 className="w-full"914 className="w-full"
913 onClick={() => {915 onClick={() => {
914 const api = readLocalApi();916 void openLink(publishResult.repository.url).catch(() => undefined);
915 if (!api) return;
916 void api.shell.openExternal(publishResult.repository.url);
917 }}917 }}
918 >918 >
919 <ExternalLinkIcon className="size-3.5" aria-hidden />
920 Open on {publishProviderLabel}919 Open on {publishProviderLabel}
921 </Button>920 </Button>
922 </>921 </>
...@@ -1004,6 +1003,7 @@ export default function GitActionsControl({...@@ -1004,6 +1003,7 @@ export default function GitActionsControl({
1004 [activeThreadRef],1003 [activeThreadRef],
1005 );1004 );
1006 const openPrLink = useOpenPrLink(activeThreadRef ?? undefined);1005 const openPrLink = useOpenPrLink(activeThreadRef ?? undefined);
1006 const openLink = useOpenLink(activeThreadRef);
1007 const activeDraftThread = useComposerDraftStore((store) =>1007 const activeDraftThread = useComposerDraftStore((store) =>
1008 draftId1008 draftId
1009 ? store.getDraftSession(draftId)1009 ? store.getDraftSession(draftId)
...@@ -1238,15 +1238,6 @@ export default function GitActionsControl({...@@ -1238,15 +1238,6 @@ export default function GitActionsControl({
1238 onOpenPullRequest(openPr.number);1238 onOpenPullRequest(openPr.number);
1239 return;1239 return;
1240 }1240 }
1241 const api = readLocalApi();
1242 if (!api) {
1243 toastManager.add({
1244 type: "error",
1245 title: "Link opening is unavailable.",
1246 data: threadToastData,
1247 });
1248 return;
1249 }
1250 const prUrl = openPr?.url ?? null;1241 const prUrl = openPr?.url ?? null;
1251 if (!prUrl) {1242 if (!prUrl) {
1252 toastManager.add({1243 toastManager.add({
...@@ -1256,7 +1247,7 @@ export default function GitActionsControl({...@@ -1256,7 +1247,7 @@ export default function GitActionsControl({
1256 });1247 });
1257 return;1248 return;
1258 }1249 }
1259 void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => {1250 void openLink(prUrl).catch((err: unknown) => {
1260 console.error(err);1251 console.error(err);
1261 toastManager.add(1252 toastManager.add(
1262 stackedThreadToast({1253 stackedThreadToast({
...@@ -1267,7 +1258,7 @@ export default function GitActionsControl({...@@ -1267,7 +1258,7 @@ export default function GitActionsControl({
1267 }),1258 }),
1268 );1259 );
1269 });1260 });
1270 }, [gitStatusForActions, onOpenPullRequest, threadToastData]);1261 }, [gitStatusForActions, onOpenPullRequest, openLink, threadToastData]);
12711262
1272 runGitActionWithToast = useEffectEvent(1263 runGitActionWithToast = useEffectEvent(
1273 async ({1264 async ({
...@@ -2010,6 +2001,7 @@ export default function GitActionsControl({...@@ -2010,6 +2001,7 @@ export default function GitActionsControl({
2010 open={isPublishDialogOpen}2001 open={isPublishDialogOpen}
2011 onOpenChange={setIsPublishDialogOpen}2002 onOpenChange={setIsPublishDialogOpen}
2012 environmentId={activeEnvironmentId}2003 environmentId={activeEnvironmentId}
2004 threadRef={activeThreadRef}
2013 gitCwd={gitCwd}2005 gitCwd={gitCwd}
2014 />2006 />
20152007
apps/web/src/components/ThreadTerminalDrawer.tsx-2
...@@ -728,10 +728,8 @@ export function TerminalViewport({...@@ -728,10 +728,8 @@ export function TerminalViewport({
728 };728 };
729 void openTerminalLinkInPreview({729 void openTerminalLinkInPreview({
730 url: text,730 url: text,
731 position: { x: event.clientX, y: event.clientY },
732 threadRef,731 threadRef,
733 openPreview,732 openPreview,
734 localApi,
735 fallbackToBrowser,733 fallbackToBrowser,
736 });734 });
737 return;735 return;
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+43-56
...@@ -1,11 +1,10 @@...@@ -1,11 +1,10 @@
1import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";1import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
3import { AsyncResult } from "effect/unstable/reactivity";3import { AsyncResult } from "effect/unstable/reactivity";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
55
6import {6import {
7 openTerminalLinkInPreview,7 openTerminalLinkInPreview,
8 TerminalLinkContextMenuShowError,
9 TerminalLinkPreviewOpenError,8 TerminalLinkPreviewOpenError,
10} from "./openTerminalLinkInPreview";9} from "./openTerminalLinkInPreview";
1110
...@@ -30,6 +29,15 @@ vi.mock("~/browser/browserDefaults", () => ({...@@ -30,6 +29,15 @@ vi.mock("~/browser/browserDefaults", () => ({
30 browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId,29 browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId,
31}));30}));
3231
32const linkTargetMocks = vi.hoisted(() => ({
33 preference: vi.fn<() => "system" | "app">(),
34}));
35
36vi.mock("~/browser/browserLinkTarget", () => ({
37 resolveBrowserLinkTargetPreference: async () => linkTargetMocks.preference(),
38 isWebUrl: (url: string) => /^https?:/u.test(url),
39}));
40
33const hydratedDefaults = {41const hydratedDefaults = {
34 viewport: { _tag: "fixed", width: 1280, height: 720 } as const,42 viewport: { _tag: "fixed", width: 1280, height: 720 } as const,
35 profileId: "work",43 profileId: "work",
...@@ -50,7 +58,9 @@ const snapshot: PreviewSessionSnapshot = {...@@ -50,7 +58,9 @@ const snapshot: PreviewSessionSnapshot = {
50};58};
5159
52beforeEach(() => {60beforeEach(() => {
61 browserDefaultsMocks.resolve.mockReset();
53 browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults);62 browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults);
63 linkTargetMocks.preference.mockReturnValue("app");
54});64});
5565
56afterEach(() => {66afterEach(() => {
...@@ -58,6 +68,37 @@ afterEach(() => {...@@ -58,6 +68,37 @@ afterEach(() => {
58});68});
5969
60describe("openTerminalLinkInPreview", () => {70describe("openTerminalLinkInPreview", () => {
71 it("opens in the system browser while that is the configured target", async () => {
72 linkTargetMocks.preference.mockReturnValue("system");
73 const fallbackToBrowser = vi.fn();
74 const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
75
76 await openTerminalLinkInPreview({
77 url: "http://localhost:3000/",
78 threadRef,
79 openPreview,
80 fallbackToBrowser,
81 });
82
83 expect(fallbackToBrowser).toHaveBeenCalledOnce();
84 expect(openPreview).not.toHaveBeenCalled();
85 });
86
87 it("opens public URLs in-app too, not only local servers", async () => {
88 const fallbackToBrowser = vi.fn();
89 const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
90
91 await openTerminalLinkInPreview({
92 url: "https://example.com/docs",
93 threadRef,
94 openPreview,
95 fallbackToBrowser,
96 });
97
98 expect(openPreview).toHaveBeenCalledOnce();
99 expect(fallbackToBrowser).not.toHaveBeenCalled();
100 });
101
61 it("waits for hydrated viewport and profile defaults before opening", async () => {102 it("waits for hydrated viewport and profile defaults before opening", async () => {
62 let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined;103 let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined;
63 browserDefaultsMocks.resolve.mockImplementationOnce(104 browserDefaultsMocks.resolve.mockImplementationOnce(
...@@ -70,14 +111,8 @@ describe("openTerminalLinkInPreview", () => {...@@ -70,14 +111,8 @@ describe("openTerminalLinkInPreview", () => {
70111
71 const opening = openTerminalLinkInPreview({112 const opening = openTerminalLinkInPreview({
72 url: "http://localhost:3000/",113 url: "http://localhost:3000/",
73 position: { x: 12, y: 34 },
74 threadRef,114 threadRef,
75 openPreview,115 openPreview,
76 localApi: {
77 contextMenu: {
78 show: vi.fn(async () => "open-in-preview"),
79 },
80 } as unknown as LocalApi,
81 fallbackToBrowser: vi.fn(),116 fallbackToBrowser: vi.fn(),
82 });117 });
83118
...@@ -97,42 +132,6 @@ describe("openTerminalLinkInPreview", () => {...@@ -97,42 +132,6 @@ describe("openTerminalLinkInPreview", () => {
97 });132 });
98 });133 });
99134
100 it("preserves context-menu failures with terminal link context before falling back", async () => {
101 const cause = new Error("menu unavailable");
102 const fallbackToBrowser = vi.fn();
103 const openPreview = vi.fn(async () => AsyncResult.success(snapshot));
104 const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined);
105
106 await openTerminalLinkInPreview({
107 url: "http://localhost:3000/path?token=secret",
108 position: { x: 12, y: 34 },
109 threadRef,
110 openPreview,
111 localApi: {
112 contextMenu: {
113 show: vi.fn(async () => {
114 throw cause;
115 }),
116 },
117 } as unknown as LocalApi,
118 fallbackToBrowser,
119 });
120
121 expect(fallbackToBrowser).toHaveBeenCalledOnce();
122 expect(openPreview).not.toHaveBeenCalled();
123 expect(reportError).toHaveBeenCalledOnce();
124 const error = reportError.mock.calls[0]?.[0];
125 expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError);
126 expect(error).toMatchObject({
127 environmentId: "local",
128 threadId: "thread-1",
129 targetOrigin: "http://localhost:3000",
130 cause,
131 });
132 expect(error.message).not.toContain("menu unavailable");
133 expect(error.targetOrigin).not.toContain("secret");
134 });
135
136 it("preserves the complete preview failure cause before falling back", async () => {135 it("preserves the complete preview failure cause before falling back", async () => {
137 const rpcError = new Error("preview unavailable");136 const rpcError = new Error("preview unavailable");
138 const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect"));137 const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect"));
...@@ -141,14 +140,8 @@ describe("openTerminalLinkInPreview", () => {...@@ -141,14 +140,8 @@ describe("openTerminalLinkInPreview", () => {
141140
142 await openTerminalLinkInPreview({141 await openTerminalLinkInPreview({
143 url: "http://127.0.0.1:5173/",142 url: "http://127.0.0.1:5173/",
144 position: { x: 12, y: 34 },
145 threadRef,143 threadRef,
146 openPreview: async () => AsyncResult.failure(cause),144 openPreview: async () => AsyncResult.failure(cause),
147 localApi: {
148 contextMenu: {
149 show: vi.fn(async () => "open-in-preview"),
150 },
151 } as unknown as LocalApi,
152 fallbackToBrowser,145 fallbackToBrowser,
153 });146 });
154147
...@@ -171,14 +164,8 @@ describe("openTerminalLinkInPreview", () => {...@@ -171,14 +164,8 @@ describe("openTerminalLinkInPreview", () => {
171164
172 await openTerminalLinkInPreview({165 await openTerminalLinkInPreview({
173 url: "http://localhost:5173/",166 url: "http://localhost:5173/",
174 position: { x: 12, y: 34 },
175 threadRef,167 threadRef,
176 openPreview: async () => AsyncResult.failure(Cause.interrupt()),168 openPreview: async () => AsyncResult.failure(Cause.interrupt()),
177 localApi: {
178 contextMenu: {
179 show: vi.fn(async () => "open-in-preview"),
180 },
181 } as unknown as LocalApi,
182 fallbackToBrowser,169 fallbackToBrowser,
183 });170 });
184171
apps/web/src/components/preview/openTerminalLinkInPreview.ts+33-63
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts";1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";2import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
3import { isPreviewableUrl } from "@t3tools/shared/preview";
4import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
54
6import {5import {
...@@ -8,6 +7,7 @@ import {...@@ -8,6 +7,7 @@ import {
8 browserDefaultOpenViewport,7 browserDefaultOpenViewport,
9 resolveBrowserDefaults,8 resolveBrowserDefaults,
10} from "~/browser/browserDefaults";9} from "~/browser/browserDefaults";
10import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget";
11import type { OpenPreviewMutation } from "~/browser/openFileInPreview";11import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
12import { recordVisitForThread } from "~/browserHistoryStore";12import { recordVisitForThread } from "~/browserHistoryStore";
13import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";13import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
...@@ -20,15 +20,6 @@ const terminalLinkErrorContext = {...@@ -20,15 +20,6 @@ const terminalLinkErrorContext = {
20 cause: Schema.Defect(),20 cause: Schema.Defect(),
21};21};
2222
23export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass<TerminalLinkContextMenuShowError>()(
24 "TerminalLinkContextMenuShowError",
25 terminalLinkErrorContext,
26) {
27 override get message(): string {
28 return `Failed to show the context menu for terminal link ${this.targetOrigin}.`;
29 }
30}
31
32export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<TerminalLinkPreviewOpenError>()(23export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<TerminalLinkPreviewOpenError>()(
33 "TerminalLinkPreviewOpenError",24 "TerminalLinkPreviewOpenError",
34 terminalLinkErrorContext,25 terminalLinkErrorContext,
...@@ -40,20 +31,26 @@ export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<Termin...@@ -40,20 +31,26 @@ export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<Termin
4031
41interface OpenTerminalLinkInPreviewInput<E> {32interface OpenTerminalLinkInPreviewInput<E> {
42 readonly url: string;33 readonly url: string;
43 readonly position: { x: number; y: number };
44 readonly threadRef: ScopedThreadRef;34 readonly threadRef: ScopedThreadRef;
45 readonly openPreview: OpenPreviewMutation<E>;35 readonly openPreview: OpenPreviewMutation<E>;
46 readonly localApi: LocalApi;
47 readonly fallbackToBrowser: () => void;36 readonly fallbackToBrowser: () => void;
48}37}
4938
39/**
40 * Opens a terminal hyperlink where the "Open links in" setting says. Terminal
41 * links are activated with the platform modifier already held, so unlike chat
42 * links the modifier cannot double as the system-browser override; the setting
43 * alone decides, and the system browser is the fallback whenever the in-app
44 * one cannot take the URL.
45 */
50export async function openTerminalLinkInPreview<E>(46export async function openTerminalLinkInPreview<E>(
51 input: OpenTerminalLinkInPreviewInput<E>,47 input: OpenTerminalLinkInPreviewInput<E>,
52): Promise<void> {48): Promise<void> {
53 const supportsPreview =49 const supportsPreview =
54 isPreviewableUrl(input.url) &&50 isWebUrl(input.url) &&
55 isPreviewSupportedInRuntime() &&51 isPreviewSupportedInRuntime() &&
56 input.threadRef.threadId.length > 0;52 input.threadRef.threadId.length > 0 &&
53 (await resolveBrowserLinkTargetPreference()) === "app";
5754
58 if (!supportsPreview) {55 if (!supportsPreview) {
59 input.fallbackToBrowser();56 input.fallbackToBrowser();
...@@ -66,59 +63,32 @@ export async function openTerminalLinkInPreview<E>(...@@ -66,59 +63,32 @@ export async function openTerminalLinkInPreview<E>(
66 targetOrigin: new URL(input.url).origin,63 targetOrigin: new URL(input.url).origin,
67 };64 };
6865
69 let choice: "open-in-preview" | "open-in-browser" | null;66 const defaults = await resolveBrowserDefaults();
70 try {67 const result = await input.openPreview({
71 choice = await input.localApi.contextMenu.show(68 environmentId: input.threadRef.environmentId,
72 [69 input: {
73 { id: "open-in-preview", label: "Open in preview" },70 threadId: input.threadRef.threadId,
74 { id: "open-in-browser", label: "Open in browser" },71 url: input.url,
75 ],72 // Same reason as `openUrlInPreview`: this path handles its own result
76 input.position,73 // mapping, so the configured defaults are applied explicitly.
77 );74 viewport: browserDefaultOpenViewport(defaults),
78 } catch (cause) {75 profileId: browserDefaultOpenProfileId(defaults),
76 },
77 });
78 if (result._tag === "Failure") {
79 if (isAtomCommandInterrupted(result)) {
80 return;
81 }
79 console.error(82 console.error(
80 new TerminalLinkContextMenuShowError({83 new TerminalLinkPreviewOpenError({
81 ...errorContext,84 ...errorContext,
82 cause,85 cause: result.cause,
83 }),86 }),
84 );87 );
85 input.fallbackToBrowser();88 input.fallbackToBrowser();
86 return;89 return;
87 }90 }
8891 recordVisitForThread(input.threadRef, input.url);
89 if (choice === "open-in-preview") {92 applyPreviewServerSnapshot(input.threadRef, result.value);
90 const defaults = await resolveBrowserDefaults();93 useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId);
91 const result = await input.openPreview({
92 environmentId: input.threadRef.environmentId,
93 input: {
94 threadId: input.threadRef.threadId,
95 url: input.url,
96 // Same reason as `openUrlInPreview`: this path handles its own result
97 // mapping, so the configured defaults are applied explicitly.
98 viewport: browserDefaultOpenViewport(defaults),
99 profileId: browserDefaultOpenProfileId(defaults),
100 },
101 });
102 if (result._tag === "Failure") {
103 if (isAtomCommandInterrupted(result)) {
104 return;
105 }
106 console.error(
107 new TerminalLinkPreviewOpenError({
108 ...errorContext,
109 cause: result.cause,
110 }),
111 );
112 input.fallbackToBrowser();
113 return;
114 }
115 recordVisitForThread(input.threadRef, input.url);
116 applyPreviewServerSnapshot(input.threadRef, result.value);
117 useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId);
118 return;
119 }
120
121 if (choice === "open-in-browser") {
122 input.fallbackToBrowser();
123 }
124}94}
apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx+30-6
...@@ -3,15 +3,17 @@ import type {...@@ -3,15 +3,17 @@ import type {
3 PullRequestCheck,3 PullRequestCheck,
4 PullRequestChecksState,4 PullRequestChecksState,
5 PullRequestRef,5 PullRequestRef,
6 ScopedThreadRef,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
78
8import { readLocalApi } from "~/localApi";9import { useOpenLink } from "~/browser/useOpenLink";
9import { cn } from "~/lib/utils";10import { cn } from "~/lib/utils";
10import { pullRequestEnvironment } from "~/state/pullRequests";11import { pullRequestEnvironment } from "~/state/pullRequests";
11import { useEnvironmentQuery } from "~/state/query";12import { useEnvironmentQuery } from "~/state/query";
1213
13import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";14import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
14import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";15import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
16import { toastManager } from "../ui/toast";
15import {17import {
16 PullRequestCheckStatusIcon,18 PullRequestCheckStatusIcon,
17 pullRequestCheckStatusLabel,19 pullRequestCheckStatusLabel,
...@@ -27,9 +29,11 @@ import {...@@ -27,9 +29,11 @@ import {
27function LazyChecksBody({29function LazyChecksBody({
28 environmentId,30 environmentId,
29 reference,31 reference,
32 threadRef,
30}: {33}: {
31 environmentId: EnvironmentId;34 environmentId: EnvironmentId;
32 reference: PullRequestRef;35 reference: PullRequestRef;
36 threadRef: ScopedThreadRef | null;
33}) {37}) {
34 const detailQuery = useEnvironmentQuery(38 const detailQuery = useEnvironmentQuery(
35 pullRequestEnvironment.detail({ environmentId, input: reference }),39 pullRequestEnvironment.detail({ environmentId, input: reference }),
...@@ -44,10 +48,17 @@ function LazyChecksBody({...@@ -44,10 +48,17 @@ function LazyChecksBody({
44 </p>48 </p>
45 );49 );
46 }50 }
47 return <ChecksBody checks={detailQuery.data.checks} />;51 return <ChecksBody checks={detailQuery.data.checks} threadRef={threadRef} />;
48}52}
4953
50function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) {54function ChecksBody({
55 checks,
56 threadRef,
57}: {
58 checks: ReadonlyArray<PullRequestCheck>;
59 threadRef: ScopedThreadRef | null;
60}) {
61 const openLink = useOpenLink(threadRef);
51 if (checks.length === 0) {62 if (checks.length === 0) {
52 return <p className="text-muted-foreground text-xs">No checks reported</p>;63 return <p className="text-muted-foreground text-xs">No checks reported</p>;
53 }64 }
...@@ -71,7 +82,13 @@ function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) {...@@ -71,7 +82,13 @@ function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) {
71 <button82 <button
72 type="button"83 type="button"
73 className="shrink-0 text-primary hover:underline"84 className="shrink-0 text-primary hover:underline"
74 onClick={() => void readLocalApi()?.shell.openExternal(check.url ?? "")}85 onClick={() => {
86 if (!check.url) return;
87 void openLink(check.url).catch((error: unknown) => {
88 console.error(error);
89 toastManager.add({ type: "error", title: "Unable to open check details" });
90 });
91 }}
75 >92 >
76 Details93 Details
77 </button>94 </button>
...@@ -94,6 +111,7 @@ export function PullRequestChecksPopover({...@@ -94,6 +111,7 @@ export function PullRequestChecksPopover({
94 checks,111 checks,
95 environmentId,112 environmentId,
96 reference,113 reference,
114 threadRef = null,
97 className,115 className,
98}: {116}: {
99 checksState: PullRequestChecksState;117 checksState: PullRequestChecksState;
...@@ -101,6 +119,8 @@ export function PullRequestChecksPopover({...@@ -101,6 +119,8 @@ export function PullRequestChecksPopover({
101 checks?: ReadonlyArray<PullRequestCheck>;119 checks?: ReadonlyArray<PullRequestCheck>;
102 environmentId?: EnvironmentId;120 environmentId?: EnvironmentId;
103 reference?: PullRequestRef;121 reference?: PullRequestRef;
122 /** Thread the popover sits beside; a listing row has none. */
123 threadRef?: ScopedThreadRef | null;
104 className?: string;124 className?: string;
105}) {125}) {
106 const presentation = pullRequestChecksStatePresentation(checksState);126 const presentation = pullRequestChecksStatePresentation(checksState);
...@@ -129,9 +149,13 @@ export function PullRequestChecksPopover({...@@ -129,9 +149,13 @@ export function PullRequestChecksPopover({
129 <p className="mb-2 font-medium text-sm">{presentation.label}</p>149 <p className="mb-2 font-medium text-sm">{presentation.label}</p>
130 {summary === null ? null : <p className="mb-2 text-muted-foreground text-xs">{summary}</p>}150 {summary === null ? null : <p className="mb-2 text-muted-foreground text-xs">{summary}</p>}
131 {checks !== undefined ? (151 {checks !== undefined ? (
132 <ChecksBody checks={checks} />152 <ChecksBody checks={checks} threadRef={threadRef} />
133 ) : environmentId !== undefined && reference !== undefined ? (153 ) : environmentId !== undefined && reference !== undefined ? (
134 <LazyChecksBody environmentId={environmentId} reference={reference} />154 <LazyChecksBody
155 environmentId={environmentId}
156 reference={reference}
157 threadRef={threadRef}
158 />
135 ) : null}159 ) : null}
136 </PopoverPopup>160 </PopoverPopup>
137 </Popover>161 </Popover>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+15-1
...@@ -445,6 +445,7 @@ function PullRequestBaseFreshnessWarning({...@@ -445,6 +445,7 @@ function PullRequestBaseFreshnessWarning({
445445
446export function PullRequestDetailPanel({446export function PullRequestDetailPanel({
447 environmentId,447 environmentId,
448 threadRef = null,
448 reference,449 reference,
449 refreshToken: forcedRefreshToken = 0,450 refreshToken: forcedRefreshToken = 0,
450 onActed,451 onActed,
...@@ -454,6 +455,13 @@ export function PullRequestDetailPanel({...@@ -454,6 +455,13 @@ export function PullRequestDetailPanel({
454 composerDraftTarget,455 composerDraftTarget,
455}: {456}: {
456 environmentId: EnvironmentId;457 environmentId: EnvironmentId;
458 /**
459 * The thread this panel sits beside, if any. Links that are not the pull
460 * request itself (check details, host permalinks) can open in that thread's
461 * in-app browser when the user has asked for it; the page has no thread, so
462 * there they always go to the system browser.
463 */
464 threadRef?: ScopedThreadRef | null;
457 reference: PullRequestRef;465 reference: PullRequestRef;
458 /**466 /**
459 * Bumped by whatever holds the panel when a reader asks for everything on screen to be read467 * Bumped by whatever holds the panel when a reader asks for everything on screen to be read
...@@ -2152,7 +2160,11 @@ export function PullRequestDetailPanel({...@@ -2152,7 +2160,11 @@ export function PullRequestDetailPanel({
2152 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}2160 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
2153 >2161 >
2154 {checksState !== null ? (2162 {checksState !== null ? (
2155 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />2163 <PullRequestChecksPopover
2164 checks={detail.checks}
2165 checksState={checksState}
2166 threadRef={threadRef}
2167 />
2156 ) : (2168 ) : (
2157 <CircleDotIcon aria-hidden className="size-3.5" />2169 <CircleDotIcon aria-hidden className="size-3.5" />
2158 )}2170 )}
...@@ -2276,6 +2288,7 @@ export function PullRequestDetailPanel({...@@ -2276,6 +2288,7 @@ export function PullRequestDetailPanel({
2276 <div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>2288 <div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>
2277 <PullRequestSummaryTab2289 <PullRequestSummaryTab
2278 environmentId={environmentId}2290 environmentId={environmentId}
2291 threadRef={threadRef}
2279 reference={reference}2292 reference={reference}
2280 detail={detail}2293 detail={detail}
2281 activityPending={activityPending}2294 activityPending={activityPending}
...@@ -2303,6 +2316,7 @@ export function PullRequestDetailPanel({...@@ -2303,6 +2316,7 @@ export function PullRequestDetailPanel({
2303 <PullRequestTimelineTab2316 <PullRequestTimelineTab
2304 detail={detail}2317 detail={detail}
2305 environmentId={environmentId}2318 environmentId={environmentId}
2319 threadRef={threadRef}
2306 reference={reference}2320 reference={reference}
2307 order={timelineOrder}2321 order={timelineOrder}
2308 onOpenCommit={openCommit}2322 onOpenCommit={openCommit}
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+5-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react";1import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react";
2import type { EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3import { createContext, useContext, useMemo } from "react";3import { createContext, useContext, useMemo } from "react";
4import type { Options as ReactMarkdownOptions } from "react-markdown";4import type { Options as ReactMarkdownOptions } from "react-markdown";
55
...@@ -21,11 +21,14 @@ export function PullRequestMarkdown({...@@ -21,11 +21,14 @@ export function PullRequestMarkdown({
21 text,21 text,
22 cwd,22 cwd,
23 environmentId,23 environmentId,
24 threadRef,
24 className,25 className,
25}: {26}: {
26 text: string;27 text: string;
27 cwd: string;28 cwd: string;
28 environmentId: EnvironmentId;29 environmentId: EnvironmentId;
30 /** Thread the body is shown beside, so its links can open in that thread's in-app browser. */
31 threadRef?: ScopedThreadRef | null;
29 className?: string;32 className?: string;
30}) {33}) {
31 const segments = splitPullRequestBody(text);34 const segments = splitPullRequestBody(text);
...@@ -43,6 +46,7 @@ export function PullRequestMarkdown({...@@ -43,6 +46,7 @@ export function PullRequestMarkdown({
43 key={segment.id}46 key={segment.id}
44 text={segment.text}47 text={segment.text}
45 cwd={cwd}48 cwd={cwd}
49 threadRef={threadRef ?? undefined}
46 environmentId={environmentId}50 environmentId={environmentId}
47 extraRemarkPlugins={extraRemarkPlugins}51 extraRemarkPlugins={extraRemarkPlugins}
48 />52 />
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx+10-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useState } from "react";1import { useState } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
33
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
55
...@@ -19,6 +19,7 @@ export function PullRequestMarkdownEditor({...@@ -19,6 +19,7 @@ export function PullRequestMarkdownEditor({
19 value,19 value,
20 cwd,20 cwd,
21 environmentId,21 environmentId,
22 threadRef = null,
22 placeholder,23 placeholder,
23 label,24 label,
24 saving,25 saving,
...@@ -30,6 +31,8 @@ export function PullRequestMarkdownEditor({...@@ -30,6 +31,8 @@ export function PullRequestMarkdownEditor({
30 readonly value: string;31 readonly value: string;
31 readonly cwd: string;32 readonly cwd: string;
32 readonly environmentId: EnvironmentId;33 readonly environmentId: EnvironmentId;
34 /** Thread the editor sits beside, so links in its preview follow the link target setting. */
35 readonly threadRef?: ScopedThreadRef | null;
33 readonly placeholder?: string | undefined;36 readonly placeholder?: string | undefined;
34 readonly label: string;37 readonly label: string;
35 readonly saving: boolean;38 readonly saving: boolean;
...@@ -84,7 +87,12 @@ export function PullRequestMarkdownEditor({...@@ -84,7 +87,12 @@ export function PullRequestMarkdownEditor({
84 {empty ? (87 {empty ? (
85 <p className="text-xs text-muted-foreground">Nothing to preview.</p>88 <p className="text-xs text-muted-foreground">Nothing to preview.</p>
86 ) : (89 ) : (
87 <PullRequestMarkdown text={draft} cwd={cwd} environmentId={environmentId} />90 <PullRequestMarkdown
91 text={draft}
92 cwd={cwd}
93 environmentId={environmentId}
94 threadRef={threadRef}
95 />
88 )}96 )}
89 </div>97 </div>
90 ) : (98 ) : (
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+15-2
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 PullRequestComment,4 PullRequestComment,
5 PullRequestDetailView,5 PullRequestDetailView,
6 PullRequestRef,6 PullRequestRef,
7 ScopedThreadRef,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import {9import {
9 ArrowDownUpIcon,10 ArrowDownUpIcon,
...@@ -23,7 +24,7 @@ import { useRef, useState, type ReactNode } from "react";...@@ -23,7 +24,7 @@ import { useRef, useState, type ReactNode } from "react";
23import { useAtomCommand } from "~/state/use-atom-command";24import { useAtomCommand } from "~/state/use-atom-command";
24import { pullRequestEnvironment } from "~/state/pullRequests";25import { pullRequestEnvironment } from "~/state/pullRequests";
25import { cn } from "~/lib/utils";26import { cn } from "~/lib/utils";
26import { readLocalApi } from "~/localApi";27import { useOpenLink } from "~/browser/useOpenLink";
27import { formatRelativeTimeLabel } from "~/timestampFormat";28import { formatRelativeTimeLabel } from "~/timestampFormat";
2829
29import { Button } from "../ui/button";30import { Button } from "../ui/button";
...@@ -93,6 +94,7 @@ function reviewStateLabel(state: string): string {...@@ -93,6 +94,7 @@ function reviewStateLabel(state: string): string {
93interface CommentEditing {94interface CommentEditing {
94 readonly cwd: string;95 readonly cwd: string;
95 readonly environmentId: EnvironmentId;96 readonly environmentId: EnvironmentId;
97 readonly threadRef: ScopedThreadRef | null;
96 readonly canEdit: (comment: PullRequestComment) => boolean;98 readonly canEdit: (comment: PullRequestComment) => boolean;
97 readonly editingId: string | null;99 readonly editingId: string | null;
98 readonly saving: boolean;100 readonly saving: boolean;
...@@ -120,6 +122,7 @@ function CommentBody({...@@ -120,6 +122,7 @@ function CommentBody({
120 value={comment.body}122 value={comment.body}
121 cwd={editing.cwd}123 cwd={editing.cwd}
122 environmentId={editing.environmentId}124 environmentId={editing.environmentId}
125 threadRef={editing.threadRef}
123 label="Edit comment"126 label="Edit comment"
124 saving={editing.saving}127 saving={editing.saving}
125 onSave={(body) => editing.onSave(comment, body)}128 onSave={(body) => editing.onSave(comment, body)}
...@@ -134,6 +137,7 @@ function CommentBody({...@@ -134,6 +137,7 @@ function CommentBody({
134 text={comment.body}137 text={comment.body}
135 cwd={editing.cwd}138 cwd={editing.cwd}
136 environmentId={editing.environmentId}139 environmentId={editing.environmentId}
140 threadRef={editing.threadRef}
137 />141 />
138 {editing.canEdit(comment) ? (142 {editing.canEdit(comment) ? (
139 <Button143 <Button
...@@ -423,6 +427,7 @@ const COMMENT_PAGE = 30;...@@ -423,6 +427,7 @@ const COMMENT_PAGE = 30;
423427
424export function PullRequestSummaryTab({428export function PullRequestSummaryTab({
425 environmentId,429 environmentId,
430 threadRef,
426 reference,431 reference,
427 detail,432 detail,
428 activityPending,433 activityPending,
...@@ -436,6 +441,7 @@ export function PullRequestSummaryTab({...@@ -436,6 +441,7 @@ export function PullRequestSummaryTab({
436 onRefresh,441 onRefresh,
437}: {442}: {
438 environmentId: EnvironmentId;443 environmentId: EnvironmentId;
444 threadRef: ScopedThreadRef | null;
439 reference: PullRequestRef;445 reference: PullRequestRef;
440 detail: PullRequestDetailView;446 detail: PullRequestDetailView;
441 activityPending: boolean;447 activityPending: boolean;
...@@ -508,8 +514,12 @@ export function PullRequestSummaryTab({...@@ -508,8 +514,12 @@ export function PullRequestSummaryTab({
508 ),514 ),
509 );515 );
510516
517 const openLink = useOpenLink(threadRef);
511 const openCheck = (url: string) => {518 const openCheck = (url: string) => {
512 void readLocalApi()?.shell.openExternal(url);519 void openLink(url).catch((error: unknown) => {
520 console.error(error);
521 toastManager.add({ type: "error", title: "Unable to open check details" });
522 });
513 };523 };
514524
515 const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false });525 const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false });
...@@ -547,6 +557,7 @@ export function PullRequestSummaryTab({...@@ -547,6 +557,7 @@ export function PullRequestSummaryTab({
547 const commentEditing: CommentEditing = {557 const commentEditing: CommentEditing = {
548 cwd: detail.workspaceRoot,558 cwd: detail.workspaceRoot,
549 environmentId,559 environmentId,
560 threadRef,
550 canEdit: (comment) => canEditPullRequestComment(detail, comment),561 canEdit: (comment) => canEditPullRequestComment(detail, comment),
551 editingId: editingCommentId,562 editingId: editingCommentId,
552 saving: commentSaving,563 saving: commentSaving,
...@@ -722,6 +733,7 @@ export function PullRequestSummaryTab({...@@ -722,6 +733,7 @@ export function PullRequestSummaryTab({
722 value={detail.body}733 value={detail.body}
723 cwd={detail.workspaceRoot}734 cwd={detail.workspaceRoot}
724 environmentId={environmentId}735 environmentId={environmentId}
736 threadRef={threadRef}
725 label="Pull request description"737 label="Pull request description"
726 placeholder="Describe this pull request"738 placeholder="Describe this pull request"
727 saving={bodySaving}739 saving={bodySaving}
...@@ -735,6 +747,7 @@ export function PullRequestSummaryTab({...@@ -735,6 +747,7 @@ export function PullRequestSummaryTab({
735 text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"}747 text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"}
736 cwd={detail.workspaceRoot}748 cwd={detail.workspaceRoot}
737 environmentId={environmentId}749 environmentId={environmentId}
750 threadRef={threadRef}
738 />751 />
739 {canEditPullRequestChangeRequest(detail) ? (752 {canEditPullRequestChangeRequest(detail) ? (
740 <Button753 <Button
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+17-1
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 PullRequestComment,4 PullRequestComment,
5 PullRequestDetailView,5 PullRequestDetailView,
6 PullRequestRef,6 PullRequestRef,
7 ScopedThreadRef,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import {9import {
9 ChevronDownIcon,10 ChevronDownIcon,
...@@ -55,6 +56,8 @@ import {...@@ -55,6 +56,8 @@ import {
55interface ReactionSurface {56interface ReactionSurface {
56 readonly canReact: boolean;57 readonly canReact: boolean;
57 readonly environmentId: EnvironmentId;58 readonly environmentId: EnvironmentId;
59 /** Thread the timeline is shown beside, so body links can open in its in-app browser. */
60 readonly threadRef: ScopedThreadRef | null;
58 readonly reference: PullRequestRef;61 readonly reference: PullRequestRef;
59 readonly onRefresh: () => void;62 readonly onRefresh: () => void;
60}63}
...@@ -64,16 +67,23 @@ function TimelineBody({...@@ -64,16 +67,23 @@ function TimelineBody({
64 markdown,67 markdown,
65 cwd,68 cwd,
66 environmentId,69 environmentId,
70 threadRef,
67}: {71}: {
68 body: string;72 body: string;
69 markdown: boolean;73 markdown: boolean;
70 cwd: string;74 cwd: string;
71 environmentId: EnvironmentId;75 environmentId: EnvironmentId;
76 threadRef: ScopedThreadRef | null;
72}) {77}) {
73 return (78 return (
74 <div className="mt-3">79 <div className="mt-3">
75 {markdown ? (80 {markdown ? (
76 <PullRequestMarkdown text={body} cwd={cwd} environmentId={environmentId} />81 <PullRequestMarkdown
82 text={body}
83 cwd={cwd}
84 environmentId={environmentId}
85 threadRef={threadRef}
86 />
77 ) : (87 ) : (
78 <p className="whitespace-pre-wrap text-xs text-muted-foreground">{body}</p>88 <p className="whitespace-pre-wrap text-xs text-muted-foreground">{body}</p>
79 )}89 )}
...@@ -246,6 +256,7 @@ function ConversationCard({...@@ -246,6 +256,7 @@ function ConversationCard({
246 value={editable.body}256 value={editable.body}
247 cwd={cwd}257 cwd={cwd}
248 environmentId={reactions.environmentId}258 environmentId={reactions.environmentId}
259 threadRef={reactions.threadRef}
249 label="Edit comment"260 label="Edit comment"
250 saving={saving}261 saving={saving}
251 onSave={(body) => void save(body)}262 onSave={(body) => void save(body)}
...@@ -259,6 +270,7 @@ function ConversationCard({...@@ -259,6 +270,7 @@ function ConversationCard({
259 markdown={event.markdown}270 markdown={event.markdown}
260 cwd={cwd}271 cwd={cwd}
261 environmentId={reactions.environmentId}272 environmentId={reactions.environmentId}
273 threadRef={reactions.threadRef}
262 />274 />
263 </div>275 </div>
264 ) : null}276 ) : null}
...@@ -526,6 +538,7 @@ function ReviewVerdictEvent({...@@ -526,6 +538,7 @@ function ReviewVerdictEvent({
526 markdown={event.markdown}538 markdown={event.markdown}
527 cwd={cwd}539 cwd={cwd}
528 environmentId={reactions.environmentId}540 environmentId={reactions.environmentId}
541 threadRef={reactions.threadRef}
529 />542 />
530 ) : null}543 ) : null}
531 </div>544 </div>
...@@ -538,6 +551,7 @@ function ReviewVerdictEvent({...@@ -538,6 +551,7 @@ function ReviewVerdictEvent({
538export function PullRequestTimelineTab({551export function PullRequestTimelineTab({
539 detail,552 detail,
540 environmentId,553 environmentId,
554 threadRef = null,
541 reference,555 reference,
542 order,556 order,
543 onOpenCommit,557 onOpenCommit,
...@@ -545,6 +559,7 @@ export function PullRequestTimelineTab({...@@ -545,6 +559,7 @@ export function PullRequestTimelineTab({
545}: {559}: {
546 detail: PullRequestDetailView;560 detail: PullRequestDetailView;
547 environmentId: EnvironmentId;561 environmentId: EnvironmentId;
562 threadRef?: ScopedThreadRef | null;
548 reference: PullRequestRef;563 reference: PullRequestRef;
549 order: "newest" | "oldest";564 order: "newest" | "oldest";
550 onOpenCommit: (oid: string) => void;565 onOpenCommit: (oid: string) => void;
...@@ -555,6 +570,7 @@ export function PullRequestTimelineTab({...@@ -555,6 +570,7 @@ export function PullRequestTimelineTab({
555 const reactions: ReactionSurface = {570 const reactions: ReactionSurface = {
556 canReact: detail.capabilities.reactions === true,571 canReact: detail.capabilities.reactions === true,
557 environmentId,572 environmentId,
573 threadRef,
558 reference,574 reference,
559 onRefresh,575 onRefresh,
560 };576 };
apps/web/src/components/settings/IntegrationsSettings.tsx+50
...@@ -8,12 +8,14 @@...@@ -8,12 +8,14 @@
8 */8 */
9import {9import {
10 BROWSER_PROFILE_MAX_COUNT,10 BROWSER_PROFILE_MAX_COUNT,
11 type BrowserLinkTarget,
11 type BrowserProfile,12 type BrowserProfile,
12 type EnvironmentId,13 type EnvironmentId,
13 BROWSER_PROFILE_NAME_MAX_LENGTH,14 BROWSER_PROFILE_NAME_MAX_LENGTH,
14 BROWSER_RECORDING_FRAME_RATES,15 BROWSER_RECORDING_FRAME_RATES,
15 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,16 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
16 DEFAULT_BROWSER_PROFILE_ID,17 DEFAULT_BROWSER_PROFILE_ID,
18 DEFAULT_BROWSER_LINK_TARGET,
17 DEFAULT_BROWSER_RECORDING_FRAME_RATE,19 DEFAULT_BROWSER_RECORDING_FRAME_RATE,
18 DEFAULT_BROWSER_VIEWPORT,20 DEFAULT_BROWSER_VIEWPORT,
19 DEFAULT_PREVIEW_APPEARANCE,21 DEFAULT_PREVIEW_APPEARANCE,
...@@ -465,6 +467,53 @@ function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boo...@@ -465,6 +467,53 @@ function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boo
465 );467 );
466}468}
467469
470const LINK_TARGET_LABELS: Readonly<Record<BrowserLinkTarget, string>> = {
471 system: "Your default browser",
472 app: "T3 Code",
473};
474
475function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) {
476 const linkTarget = useClientSettings((settings) => settings.browserLinkTarget);
477 const updateSettings = useUpdatePrimarySettings();
478
479 return (
480 <SettingsRow
481 {...searchableSetting("browser-link-target")}
482 description="Where links in the chat and terminal open. Hold ⌘ or Ctrl while clicking a chat link to open it in your default browser either way."
483 resetAction={
484 !disabled && linkTarget !== DEFAULT_BROWSER_LINK_TARGET ? (
485 <SettingResetButton
486 label="link target"
487 onClick={() => updateSettings({ browserLinkTarget: DEFAULT_BROWSER_LINK_TARGET })}
488 />
489 ) : null
490 }
491 control={
492 <Select
493 disabled={disabled}
494 value={linkTarget}
495 onValueChange={(value) => {
496 if (value === "system" || value === "app") {
497 updateSettings({ browserLinkTarget: value });
498 }
499 }}
500 >
501 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Open links in">
502 <SelectValue>{LINK_TARGET_LABELS[linkTarget]}</SelectValue>
503 </SelectTrigger>
504 <SelectPopup align="end" alignItemWithTrigger={false}>
505 {(Object.keys(LINK_TARGET_LABELS) as ReadonlyArray<BrowserLinkTarget>).map((target) => (
506 <SelectItem hideIndicator key={target} value={target}>
507 {LINK_TARGET_LABELS[target]}
508 </SelectItem>
509 ))}
510 </SelectPopup>
511 </Select>
512 }
513 />
514 );
515}
516
468function AgentBrowserAccessSetting() {517function AgentBrowserAccessSetting() {
469 const settings = usePrimarySettings();518 const settings = usePrimarySettings();
470 const updateSettings = useUpdatePrimarySettings();519 const updateSettings = useUpdatePrimarySettings();
...@@ -868,6 +917,7 @@ export function IntegrationsSettingsPanel() {...@@ -868,6 +917,7 @@ export function IntegrationsSettingsPanel() {
868 <BrowserZoomSetting disabled={previewDefaultsDisabled} />917 <BrowserZoomSetting disabled={previewDefaultsDisabled} />
869 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />918 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />
870 <BrowserRecordingFrameRateSetting disabled={previewDefaultsDisabled} />919 <BrowserRecordingFrameRateSetting disabled={previewDefaultsDisabled} />
920 <BrowserLinkTargetSetting disabled={previewDefaultsDisabled} />
871 <BrowserAutoShowFloatingPreviewSetting disabled={previewDefaultsDisabled} />921 <BrowserAutoShowFloatingPreviewSetting disabled={previewDefaultsDisabled} />
872 </>922 </>
873 );923 );
apps/web/src/components/settings/SettingsPanels.logic.test.ts+2
...@@ -269,6 +269,7 @@ describe("getChangedBrowserSettingLabels", () => {...@@ -269,6 +269,7 @@ describe("getChangedBrowserSettingLabels", () => {
269 browserDefaultZoomFactor: 1.5,269 browserDefaultZoomFactor: 1.5,
270 browserDefaultAppearance: "dark",270 browserDefaultAppearance: "dark",
271 browserRecordingFrameRate: 60,271 browserRecordingFrameRate: 60,
272 browserLinkTarget: "app",
272 browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,273 browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
273 }),274 }),
274 ).toEqual([275 ).toEqual([
...@@ -276,6 +277,7 @@ describe("getChangedBrowserSettingLabels", () => {...@@ -276,6 +277,7 @@ describe("getChangedBrowserSettingLabels", () => {
276 "Browser zoom",277 "Browser zoom",
277 "Browser appearance",278 "Browser appearance",
278 "Recording frame rate",279 "Recording frame rate",
280 "Open links in",
279 "Floating preview",281 "Floating preview",
280 ]);282 ]);
281 });283 });
apps/web/src/components/settings/SettingsPanels.logic.ts+4
...@@ -115,6 +115,7 @@ export type BrowserDefaultSettings = Pick<...@@ -115,6 +115,7 @@ export type BrowserDefaultSettings = Pick<
115 | "browserDefaultZoomFactor"115 | "browserDefaultZoomFactor"
116 | "browserDefaultAppearance"116 | "browserDefaultAppearance"
117 | "browserRecordingFrameRate"117 | "browserRecordingFrameRate"
118 | "browserLinkTarget"
118 | "browserAutoShowFloatingPreview"119 | "browserAutoShowFloatingPreview"
119>;120>;
120121
...@@ -155,6 +156,9 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings)...@@ -155,6 +156,9 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings)
155 ...(settings.browserRecordingFrameRate !== DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate156 ...(settings.browserRecordingFrameRate !== DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate
156 ? ["Recording frame rate"]157 ? ["Recording frame rate"]
157 : []),158 : []),
159 ...(settings.browserLinkTarget !== DEFAULT_UNIFIED_SETTINGS.browserLinkTarget
160 ? ["Open links in"]
161 : []),
158 ...(settings.browserAutoShowFloatingPreview !==162 ...(settings.browserAutoShowFloatingPreview !==
159 DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview163 DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview
160 ? ["Floating preview"]164 ? ["Floating preview"]
apps/web/src/components/settings/SettingsPanels.tsx+2
...@@ -589,6 +589,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -589,6 +589,7 @@ export function useSettingsRestore(onRestored?: () => void) {
589 settings.browserDefaultZoomFactor,589 settings.browserDefaultZoomFactor,
590 settings.browserDefaultAppearance,590 settings.browserDefaultAppearance,
591 settings.browserRecordingFrameRate,591 settings.browserRecordingFrameRate,
592 settings.browserLinkTarget,
592 settings.browserAutoShowFloatingPreview,593 settings.browserAutoShowFloatingPreview,
593 settings.appearanceContrast,594 settings.appearanceContrast,
594 settings.enableAgentBrowserAccess,595 settings.enableAgentBrowserAccess,
...@@ -735,6 +736,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -735,6 +736,7 @@ export function useSettingsRestore(onRestored?: () => void) {
735 browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor,736 browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor,
736 browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance,737 browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance,
737 browserRecordingFrameRate: DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate,738 browserRecordingFrameRate: DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate,
739 browserLinkTarget: DEFAULT_UNIFIED_SETTINGS.browserLinkTarget,
738 browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,740 browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
739 // Re-granted like any other default. The confirmation dialog lists it by741 // Re-granted like any other default. The confirmation dialog lists it by
740 // name, so a user restoring defaults is told the agent regains access742 // name, so a user restoring defaults is told the agent regains access
apps/web/src/components/settings/settingsSearch.test.ts+8
...@@ -207,4 +207,12 @@ describe("searchSettings", () => {...@@ -207,4 +207,12 @@ describe("searchSettings", () => {
207 });207 });
208 expect(result).not.toHaveProperty("targetId");208 expect(result).not.toHaveProperty("targetId");
209 });209 });
210
211 it("routes where links open to integrations", () => {
212 expect(searchSettings("open links in")[0]).toMatchObject({
213 id: "browser-link-target",
214 to: "/settings/integrations",
215 });
216 expect(searchSettings("external links")[0]).toMatchObject({ id: "browser-link-target" });
217 });
210});218});
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -364,6 +364,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -364,6 +364,12 @@ export const SETTINGS_SEARCH_ITEMS = [
364 title: "Browser recording frame rate",364 title: "Browser recording frame rate",
365 to: "/settings/integrations",365 to: "/settings/integrations",
366 },366 },
367 {
368 id: "browser-link-target",
369 title: "Open links in",
370 to: "/settings/integrations",
371 searchTerms: ["links default browser in-app browser external open"],
372 },
367 {373 {
368 id: "browser-auto-show-floating-preview",374 id: "browser-auto-show-floating-preview",
369 title: "Auto-show floating preview",375 title: "Auto-show floating preview",
apps/web/src/lib/openPullRequestLink.ts+6-12
...@@ -11,8 +11,8 @@ import { type MouseEvent, useCallback } from "react";...@@ -11,8 +11,8 @@ import { type MouseEvent, useCallback } from "react";
1111
12import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";12import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
1313
14import { useOpenLink } from "../browser/useOpenLink";
14import { stackedThreadToast, toastManager } from "../components/ui/toast";15import { stackedThreadToast, toastManager } from "../components/ui/toast";
15import { readLocalApi } from "../localApi";
16import { useRightPanelStore } from "../rightPanelStore";16import { useRightPanelStore } from "../rightPanelStore";
17import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";17import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
1818
...@@ -328,6 +328,7 @@ export function useOpenChangeRequestLink(...@@ -328,6 +328,7 @@ export function useOpenChangeRequestLink(
328328
329export function useOpenPrLink(threadRef?: ScopedThreadRef) {329export function useOpenPrLink(threadRef?: ScopedThreadRef) {
330 const openChangeRequest = useOpenChangeRequestLink(threadRef);330 const openChangeRequest = useOpenChangeRequestLink(threadRef);
331 const openLink = useOpenLink(threadRef);
331 return useCallback(332 return useCallback(
332 (event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => {333 (event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => {
333 event.stopPropagation();334 event.stopPropagation();
...@@ -342,16 +343,9 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) {...@@ -342,16 +343,9 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) {
342 event.preventDefault();343 event.preventDefault();
343 if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true;344 if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true;
344345
345 const api = readLocalApi();346 // No project to show it in, so it is an ordinary link and follows the
346 if (!api) {347 // "Open links in" setting; the modifier still forces the system browser.
347 toastManager.add({348 void openLink(prUrl, { event, threadRef: targetThreadRef }).catch((error: unknown) => {
348 type: "error",
349 title: "Link opening is unavailable.",
350 });
351 return false;
352 }
353
354 void openPullRequestLink(api.shell, prUrl).catch((error) => {
355 console.error(error);349 console.error(error);
356 toastManager.add(350 toastManager.add(
357 stackedThreadToast({351 stackedThreadToast({
...@@ -363,6 +357,6 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) {...@@ -363,6 +357,6 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) {
363 });357 });
364 return false;358 return false;
365 },359 },
366 [openChangeRequest],360 [openChangeRequest, openLink],
367 );361 );
368}362}
packages/contracts/src/settings.ts+16
...@@ -188,6 +188,14 @@ export const BROWSER_RECORDING_FRAME_RATES = [30, 60] as const;...@@ -188,6 +188,14 @@ export const BROWSER_RECORDING_FRAME_RATES = [30, 60] as const;
188export const BrowserRecordingFrameRate = Schema.Literals(BROWSER_RECORDING_FRAME_RATES);188export const BrowserRecordingFrameRate = Schema.Literals(BROWSER_RECORDING_FRAME_RATES);
189export type BrowserRecordingFrameRate = typeof BrowserRecordingFrameRate.Type;189export type BrowserRecordingFrameRate = typeof BrowserRecordingFrameRate.Type;
190export const DEFAULT_BROWSER_RECORDING_FRAME_RATE: BrowserRecordingFrameRate = 30;190export const DEFAULT_BROWSER_RECORDING_FRAME_RATE: BrowserRecordingFrameRate = 30;
191/**
192 * Where a clicked link goes: the OS default browser, or a tab in the in-app
193 * browser beside the thread. "system" is the default because that is what
194 * every link did before the setting existed.
195 */
196export const BrowserLinkTarget = Schema.Literals(["system", "app"]);
197export type BrowserLinkTarget = typeof BrowserLinkTarget.Type;
198export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system";
191199
192export const ClientSettingsSchema = Schema.Struct({200export const ClientSettingsSchema = Schema.Struct({
193 appearanceContrast: AppearanceContrast.pipe(201 appearanceContrast: AppearanceContrast.pipe(
...@@ -210,6 +218,13 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -210,6 +218,13 @@ export const ClientSettingsSchema = Schema.Struct({
210 browserRecordingFrameRate: BrowserRecordingFrameRate.pipe(218 browserRecordingFrameRate: BrowserRecordingFrameRate.pipe(
211 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_RECORDING_FRAME_RATE)),219 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_RECORDING_FRAME_RATE)),
212 ),220 ),
221 /**
222 * Where links clicked in a thread (chat markdown, terminal output) open.
223 * Only the desktop app has an in-app browser, so other clients ignore "app".
224 */
225 browserLinkTarget: BrowserLinkTarget.pipe(
226 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_LINK_TARGET)),
227 ),
213 /**228 /**
214 * Whether an agent opening a preview pops the floating mini player into229 * Whether an agent opening a preview pops the floating mini player into
215 * view. Only applies when the agent didn't ask either way — an explicit230 * view. Only applies when the agent didn't ask either way — an explicit
...@@ -1003,6 +1018,7 @@ export const ClientSettingsPatch = Schema.Struct({...@@ -1003,6 +1018,7 @@ export const ClientSettingsPatch = Schema.Struct({
1003 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),1018 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
1004 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),1019 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),
1005 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),1020 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),
1021 browserLinkTarget: Schema.optionalKey(BrowserLinkTarget),
1006 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),1022 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),
1007 browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)),1023 browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)),
1008 browserDefaultProfileId: Schema.optionalKey(BrowserProfileId),1024 browserDefaultProfileId: Schema.optionalKey(BrowserProfileId),