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 = {
1818 browserDefaultZoomFactor: 1.25,
1919 browserDefaultAppearance: "dark",
2020 browserRecordingFrameRate: 60,
21 browserLinkTarget: "app",
2122 browserAutoShowFloatingPreview: false,
2223 browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
2324 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";
119119import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
120120import { RenderErrorBoundary } from "./RenderErrorBoundary";
121121import { useTheme } from "../hooks/useTheme";
122import { getClientSettings } from "../hooks/useSettings";
122import { getClientSettings, useClientSettings } from "../hooks/useSettings";
123123import {
124124 chatMarkdownClipboardPayload,
125125 serializeTableElementToCsv,
......@@ -173,6 +173,7 @@ import {
173173 openUrlInPreview,
174174 BrowserPreviewUnavailableError,
175175} from "../browser/openFileInPreview";
176import { resolveLinkTarget } from "../browser/browserLinkTarget";
176177
177178interface ChatMarkdownProps {
178179 text: string;
......@@ -2120,6 +2121,10 @@ function ChatMarkdown({
21202121 event.clipboardData.setData("text/html", payload.html);
21212122 }, []);
21222123 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);
21232128 const resolveThreadPullRequest = useCallback(
21242129 (href: string): ThreadLinkedPullRequest | null => {
21252130 if (
......@@ -2472,9 +2477,35 @@ function ChatMarkdown({
24722477 }
24732478 // A link to a change request in a workspace project opens beside the
24742479 // 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 is
2476 // an ordinary link and keeps the `_blank` the shell already handles.
2477 if (href) openChangeRequestLink(event, href);
2480 // the panel it opens offers the browser as one of its actions.
2481 if (!href || openChangeRequestLink(event, href)) return;
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 });
24782509 }}
24792510 onContextMenu={(event) => {
24802511 if (!href || !faviconHost) return;
......@@ -2711,6 +2742,7 @@ function ChatMarkdown({
27112742 inlineCodeFileLinkMetaByText,
27122743 imageBaseDir,
27132744 isStreaming,
2745 linkTargetPreference,
27142746 markdownFileLinkMetaByHref,
27152747 onTaskListChange,
27162748 onUseArtifactTemplate,
apps/web/src/components/ChatView.tsx+1
......@@ -7323,6 +7323,7 @@ function ChatViewContent(props: ChatViewProps) {
73237323 <PullRequestDetailPanel
73247324 key={`${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
73257325 environmentId={activeThread.environmentId}
7326 threadRef={activeThreadRef}
73267327 reference={{
73277328 projectId: renderedRightPanelSurface.projectId as ProjectId,
73287329 repository: renderedRightPanelSurface.repository,
apps/web/src/components/GitActionsControl.tsx+10-18
......@@ -32,7 +32,6 @@ import {
3232 ChevronDownIcon,
3333 CloudDownloadIcon,
3434 CloudUploadIcon,
35 ExternalLinkIcon,
3635 GitBranchPlusIcon,
3736 GitCommitIcon,
3837 InfoIcon,
......@@ -97,9 +96,9 @@ import { vcsEnvironment } from "~/state/vcs";
9796import { randomUUID } from "~/lib/utils";
9897import { resolvePathLinkTarget } from "~/terminal-links";
9998import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
100import { readLocalApi } from "~/localApi";
10199import { getSourceControlPresentation } from "~/sourceControlPresentation";
102import { openPullRequestLink, useOpenPrLink } from "~/lib/openPullRequestLink";
100import { useOpenLink } from "~/browser/useOpenLink";
101import { useOpenPrLink } from "~/lib/openPullRequestLink";
103102
104103interface GitActionsControlProps {
105104 gitCwd: string | null;
......@@ -384,10 +383,13 @@ interface PublishRepositoryDialogProps {
384383 readonly open: boolean;
385384 readonly onOpenChange: (open: boolean) => void;
386385 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;
387388 readonly gitCwd: string;
388389}
389390
390391function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
392 const openLink = useOpenLink(props.threadRef);
391393 const navigate = useNavigate();
392394 const sourceControlDiscovery = useEnvironmentQuery(
393395 props.environmentId === null
......@@ -911,12 +913,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
911913 size="sm"
912914 className="w-full"
913915 onClick={() => {
914 const api = readLocalApi();
915 if (!api) return;
916 void api.shell.openExternal(publishResult.repository.url);
916 void openLink(publishResult.repository.url).catch(() => undefined);
917917 }}
918918 >
919 <ExternalLinkIcon className="size-3.5" aria-hidden />
920919 Open on {publishProviderLabel}
921920 </Button>
922921 </>
......@@ -1004,6 +1003,7 @@ export default function GitActionsControl({
10041003 [activeThreadRef],
10051004 );
10061005 const openPrLink = useOpenPrLink(activeThreadRef ?? undefined);
1006 const openLink = useOpenLink(activeThreadRef);
10071007 const activeDraftThread = useComposerDraftStore((store) =>
10081008 draftId
10091009 ? store.getDraftSession(draftId)
......@@ -1238,15 +1238,6 @@ export default function GitActionsControl({
12381238 onOpenPullRequest(openPr.number);
12391239 return;
12401240 }
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 }
12501241 const prUrl = openPr?.url ?? null;
12511242 if (!prUrl) {
12521243 toastManager.add({
......@@ -1256,7 +1247,7 @@ export default function GitActionsControl({
12561247 });
12571248 return;
12581249 }
1259 void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => {
1250 void openLink(prUrl).catch((err: unknown) => {
12601251 console.error(err);
12611252 toastManager.add(
12621253 stackedThreadToast({
......@@ -1267,7 +1258,7 @@ export default function GitActionsControl({
12671258 }),
12681259 );
12691260 });
1270 }, [gitStatusForActions, onOpenPullRequest, threadToastData]);
1261 }, [gitStatusForActions, onOpenPullRequest, openLink, threadToastData]);
12711262
12721263 runGitActionWithToast = useEffectEvent(
12731264 async ({
......@@ -2010,6 +2001,7 @@ export default function GitActionsControl({
20102001 open={isPublishDialogOpen}
20112002 onOpenChange={setIsPublishDialogOpen}
20122003 environmentId={activeEnvironmentId}
2004 threadRef={activeThreadRef}
20132005 gitCwd={gitCwd}
20142006 />
20152007
apps/web/src/components/ThreadTerminalDrawer.tsx-2
......@@ -728,10 +728,8 @@ export function TerminalViewport({
728728 };
729729 void openTerminalLinkInPreview({
730730 url: text,
731 position: { x: event.clientX, y: event.clientY },
732731 threadRef,
733732 openPreview,
734 localApi,
735733 fallbackToBrowser,
736734 });
737735 return;
apps/web/src/components/preview/openTerminalLinkInPreview.test.ts+43-56
......@@ -1,11 +1,10 @@
1import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
1import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
22import * as Cause from "effect/Cause";
33import { AsyncResult } from "effect/unstable/reactivity";
44import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
55
66import {
77 openTerminalLinkInPreview,
8 TerminalLinkContextMenuShowError,
98 TerminalLinkPreviewOpenError,
109} from "./openTerminalLinkInPreview";
1110
......@@ -30,6 +29,15 @@ vi.mock("~/browser/browserDefaults", () => ({
3029 browserDefaultOpenProfileId: (defaults: { profileId: string }) => defaults.profileId,
3130}));
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
3341const hydratedDefaults = {
3442 viewport: { _tag: "fixed", width: 1280, height: 720 } as const,
3543 profileId: "work",
......@@ -50,7 +58,9 @@ const snapshot: PreviewSessionSnapshot = {
5058};
5159
5260beforeEach(() => {
61 browserDefaultsMocks.resolve.mockReset();
5362 browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults);
63 linkTargetMocks.preference.mockReturnValue("app");
5464});
5565
5666afterEach(() => {
......@@ -58,6 +68,37 @@ afterEach(() => {
5868});
5969
6070describe("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
61102 it("waits for hydrated viewport and profile defaults before opening", async () => {
62103 let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined;
63104 browserDefaultsMocks.resolve.mockImplementationOnce(
......@@ -70,14 +111,8 @@ describe("openTerminalLinkInPreview", () => {
70111
71112 const opening = openTerminalLinkInPreview({
72113 url: "http://localhost:3000/",
73 position: { x: 12, y: 34 },
74114 threadRef,
75115 openPreview,
76 localApi: {
77 contextMenu: {
78 show: vi.fn(async () => "open-in-preview"),
79 },
80 } as unknown as LocalApi,
81116 fallbackToBrowser: vi.fn(),
82117 });
83118
......@@ -97,42 +132,6 @@ describe("openTerminalLinkInPreview", () => {
97132 });
98133 });
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
136135 it("preserves the complete preview failure cause before falling back", async () => {
137136 const rpcError = new Error("preview unavailable");
138137 const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect"));
......@@ -141,14 +140,8 @@ describe("openTerminalLinkInPreview", () => {
141140
142141 await openTerminalLinkInPreview({
143142 url: "http://127.0.0.1:5173/",
144 position: { x: 12, y: 34 },
145143 threadRef,
146144 openPreview: async () => AsyncResult.failure(cause),
147 localApi: {
148 contextMenu: {
149 show: vi.fn(async () => "open-in-preview"),
150 },
151 } as unknown as LocalApi,
152145 fallbackToBrowser,
153146 });
154147
......@@ -171,14 +164,8 @@ describe("openTerminalLinkInPreview", () => {
171164
172165 await openTerminalLinkInPreview({
173166 url: "http://localhost:5173/",
174 position: { x: 12, y: 34 },
175167 threadRef,
176168 openPreview: async () => AsyncResult.failure(Cause.interrupt()),
177 localApi: {
178 contextMenu: {
179 show: vi.fn(async () => "open-in-preview"),
180 },
181 } as unknown as LocalApi,
182169 fallbackToBrowser,
183170 });
184171
apps/web/src/components/preview/openTerminalLinkInPreview.ts+33-63
......@@ -1,6 +1,5 @@
1import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts";
1import type { ScopedThreadRef } from "@t3tools/contracts";
22import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
3import { isPreviewableUrl } from "@t3tools/shared/preview";
43import * as Schema from "effect/Schema";
54
65import {
......@@ -8,6 +7,7 @@ import {
87 browserDefaultOpenViewport,
98 resolveBrowserDefaults,
109} from "~/browser/browserDefaults";
10import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget";
1111import type { OpenPreviewMutation } from "~/browser/openFileInPreview";
1212import { recordVisitForThread } from "~/browserHistoryStore";
1313import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore";
......@@ -20,15 +20,6 @@ const terminalLinkErrorContext = {
2020 cause: Schema.Defect(),
2121};
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
3223export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<TerminalLinkPreviewOpenError>()(
3324 "TerminalLinkPreviewOpenError",
3425 terminalLinkErrorContext,
......@@ -40,20 +31,26 @@ export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass<Termin
4031
4132interface OpenTerminalLinkInPreviewInput<E> {
4233 readonly url: string;
43 readonly position: { x: number; y: number };
4434 readonly threadRef: ScopedThreadRef;
4535 readonly openPreview: OpenPreviewMutation<E>;
46 readonly localApi: LocalApi;
4736 readonly fallbackToBrowser: () => void;
4837}
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 */
5046export async function openTerminalLinkInPreview<E>(
5147 input: OpenTerminalLinkInPreviewInput<E>,
5248): Promise<void> {
5349 const supportsPreview =
54 isPreviewableUrl(input.url) &&
50 isWebUrl(input.url) &&
5551 isPreviewSupportedInRuntime() &&
56 input.threadRef.threadId.length > 0;
52 input.threadRef.threadId.length > 0 &&
53 (await resolveBrowserLinkTargetPreference()) === "app";
5754
5855 if (!supportsPreview) {
5956 input.fallbackToBrowser();
......@@ -66,59 +63,32 @@ export async function openTerminalLinkInPreview<E>(
6663 targetOrigin: new URL(input.url).origin,
6764 };
6865
69 let choice: "open-in-preview" | "open-in-browser" | null;
70 try {
71 choice = await input.localApi.contextMenu.show(
72 [
73 { id: "open-in-preview", label: "Open in preview" },
74 { id: "open-in-browser", label: "Open in browser" },
75 ],
76 input.position,
77 );
78 } catch (cause) {
66 const defaults = await resolveBrowserDefaults();
67 const result = await input.openPreview({
68 environmentId: input.threadRef.environmentId,
69 input: {
70 threadId: input.threadRef.threadId,
71 url: input.url,
72 // Same reason as `openUrlInPreview`: this path handles its own result
73 // mapping, so the configured defaults are applied explicitly.
74 viewport: browserDefaultOpenViewport(defaults),
75 profileId: browserDefaultOpenProfileId(defaults),
76 },
77 });
78 if (result._tag === "Failure") {
79 if (isAtomCommandInterrupted(result)) {
80 return;
81 }
7982 console.error(
80 new TerminalLinkContextMenuShowError({
83 new TerminalLinkPreviewOpenError({
8184 ...errorContext,
82 cause,
85 cause: result.cause,
8386 }),
8487 );
8588 input.fallbackToBrowser();
8689 return;
8790 }
88
89 if (choice === "open-in-preview") {
90 const defaults = await resolveBrowserDefaults();
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 }
91 recordVisitForThread(input.threadRef, input.url);
92 applyPreviewServerSnapshot(input.threadRef, result.value);
93 useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId);
12494}
apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx+30-6
......@@ -3,15 +3,17 @@ import type {
33 PullRequestCheck,
44 PullRequestChecksState,
55 PullRequestRef,
6 ScopedThreadRef,
67} from "@t3tools/contracts";
78
8import { readLocalApi } from "~/localApi";
9import { useOpenLink } from "~/browser/useOpenLink";
910import { cn } from "~/lib/utils";
1011import { pullRequestEnvironment } from "~/state/pullRequests";
1112import { useEnvironmentQuery } from "~/state/query";
1213
1314import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
1415import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
16import { toastManager } from "../ui/toast";
1517import {
1618 PullRequestCheckStatusIcon,
1719 pullRequestCheckStatusLabel,
......@@ -27,9 +29,11 @@ import {
2729function LazyChecksBody({
2830 environmentId,
2931 reference,
32 threadRef,
3033}: {
3134 environmentId: EnvironmentId;
3235 reference: PullRequestRef;
36 threadRef: ScopedThreadRef | null;
3337}) {
3438 const detailQuery = useEnvironmentQuery(
3539 pullRequestEnvironment.detail({ environmentId, input: reference }),
......@@ -44,10 +48,17 @@ function LazyChecksBody({
4448 </p>
4549 );
4650 }
47 return <ChecksBody checks={detailQuery.data.checks} />;
51 return <ChecksBody checks={detailQuery.data.checks} threadRef={threadRef} />;
4852}
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);
5162 if (checks.length === 0) {
5263 return <p className="text-muted-foreground text-xs">No checks reported</p>;
5364 }
......@@ -71,7 +82,13 @@ function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) {
7182 <button
7283 type="button"
7384 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 }}
7592 >
7693 Details
7794 </button>
......@@ -94,6 +111,7 @@ export function PullRequestChecksPopover({
94111 checks,
95112 environmentId,
96113 reference,
114 threadRef = null,
97115 className,
98116}: {
99117 checksState: PullRequestChecksState;
......@@ -101,6 +119,8 @@ export function PullRequestChecksPopover({
101119 checks?: ReadonlyArray<PullRequestCheck>;
102120 environmentId?: EnvironmentId;
103121 reference?: PullRequestRef;
122 /** Thread the popover sits beside; a listing row has none. */
123 threadRef?: ScopedThreadRef | null;
104124 className?: string;
105125}) {
106126 const presentation = pullRequestChecksStatePresentation(checksState);
......@@ -129,9 +149,13 @@ export function PullRequestChecksPopover({
129149 <p className="mb-2 font-medium text-sm">{presentation.label}</p>
130150 {summary === null ? null : <p className="mb-2 text-muted-foreground text-xs">{summary}</p>}
131151 {checks !== undefined ? (
132 <ChecksBody checks={checks} />
152 <ChecksBody checks={checks} threadRef={threadRef} />
133153 ) : environmentId !== undefined && reference !== undefined ? (
134 <LazyChecksBody environmentId={environmentId} reference={reference} />
154 <LazyChecksBody
155 environmentId={environmentId}
156 reference={reference}
157 threadRef={threadRef}
158 />
135159 ) : null}
136160 </PopoverPopup>
137161 </Popover>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+15-1
......@@ -445,6 +445,7 @@ function PullRequestBaseFreshnessWarning({
445445
446446export function PullRequestDetailPanel({
447447 environmentId,
448 threadRef = null,
448449 reference,
449450 refreshToken: forcedRefreshToken = 0,
450451 onActed,
......@@ -454,6 +455,13 @@ export function PullRequestDetailPanel({
454455 composerDraftTarget,
455456}: {
456457 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;
457465 reference: PullRequestRef;
458466 /**
459467 * Bumped by whatever holds the panel when a reader asks for everything on screen to be read
......@@ -2152,7 +2160,11 @@ export function PullRequestDetailPanel({
21522160 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
21532161 >
21542162 {checksState !== null ? (
2155 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
2163 <PullRequestChecksPopover
2164 checks={detail.checks}
2165 checksState={checksState}
2166 threadRef={threadRef}
2167 />
21562168 ) : (
21572169 <CircleDotIcon aria-hidden className="size-3.5" />
21582170 )}
......@@ -2276,6 +2288,7 @@ export function PullRequestDetailPanel({
22762288 <div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>
22772289 <PullRequestSummaryTab
22782290 environmentId={environmentId}
2291 threadRef={threadRef}
22792292 reference={reference}
22802293 detail={detail}
22812294 activityPending={activityPending}
......@@ -2303,6 +2316,7 @@ export function PullRequestDetailPanel({
23032316 <PullRequestTimelineTab
23042317 detail={detail}
23052318 environmentId={environmentId}
2319 threadRef={threadRef}
23062320 reference={reference}
23072321 order={timelineOrder}
23082322 onOpenCommit={openCommit}
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+5-1
......@@ -1,5 +1,5 @@
11import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
33import { createContext, useContext, useMemo } from "react";
44import type { Options as ReactMarkdownOptions } from "react-markdown";
55
......@@ -21,11 +21,14 @@ export function PullRequestMarkdown({
2121 text,
2222 cwd,
2323 environmentId,
24 threadRef,
2425 className,
2526}: {
2627 text: string;
2728 cwd: string;
2829 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;
2932 className?: string;
3033}) {
3134 const segments = splitPullRequestBody(text);
......@@ -43,6 +46,7 @@ export function PullRequestMarkdown({
4346 key={segment.id}
4447 text={segment.text}
4548 cwd={cwd}
49 threadRef={threadRef ?? undefined}
4650 environmentId={environmentId}
4751 extraRemarkPlugins={extraRemarkPlugins}
4852 />
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx+10-2
......@@ -1,5 +1,5 @@
11import { useState } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
33
44import { cn } from "~/lib/utils";
55
......@@ -19,6 +19,7 @@ export function PullRequestMarkdownEditor({
1919 value,
2020 cwd,
2121 environmentId,
22 threadRef = null,
2223 placeholder,
2324 label,
2425 saving,
......@@ -30,6 +31,8 @@ export function PullRequestMarkdownEditor({
3031 readonly value: string;
3132 readonly cwd: string;
3233 readonly environmentId: EnvironmentId;
34 /** Thread the editor sits beside, so links in its preview follow the link target setting. */
35 readonly threadRef?: ScopedThreadRef | null;
3336 readonly placeholder?: string | undefined;
3437 readonly label: string;
3538 readonly saving: boolean;
......@@ -84,7 +87,12 @@ export function PullRequestMarkdownEditor({
8487 {empty ? (
8588 <p className="text-xs text-muted-foreground">Nothing to preview.</p>
8689 ) : (
87 <PullRequestMarkdown text={draft} cwd={cwd} environmentId={environmentId} />
90 <PullRequestMarkdown
91 text={draft}
92 cwd={cwd}
93 environmentId={environmentId}
94 threadRef={threadRef}
95 />
8896 )}
8997 </div>
9098 ) : (
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+15-2
......@@ -4,6 +4,7 @@ import type {
44 PullRequestComment,
55 PullRequestDetailView,
66 PullRequestRef,
7 ScopedThreadRef,
78} from "@t3tools/contracts";
89import {
910 ArrowDownUpIcon,
......@@ -23,7 +24,7 @@ import { useRef, useState, type ReactNode } from "react";
2324import { useAtomCommand } from "~/state/use-atom-command";
2425import { pullRequestEnvironment } from "~/state/pullRequests";
2526import { cn } from "~/lib/utils";
26import { readLocalApi } from "~/localApi";
27import { useOpenLink } from "~/browser/useOpenLink";
2728import { formatRelativeTimeLabel } from "~/timestampFormat";
2829
2930import { Button } from "../ui/button";
......@@ -93,6 +94,7 @@ function reviewStateLabel(state: string): string {
9394interface CommentEditing {
9495 readonly cwd: string;
9596 readonly environmentId: EnvironmentId;
97 readonly threadRef: ScopedThreadRef | null;
9698 readonly canEdit: (comment: PullRequestComment) => boolean;
9799 readonly editingId: string | null;
98100 readonly saving: boolean;
......@@ -120,6 +122,7 @@ function CommentBody({
120122 value={comment.body}
121123 cwd={editing.cwd}
122124 environmentId={editing.environmentId}
125 threadRef={editing.threadRef}
123126 label="Edit comment"
124127 saving={editing.saving}
125128 onSave={(body) => editing.onSave(comment, body)}
......@@ -134,6 +137,7 @@ function CommentBody({
134137 text={comment.body}
135138 cwd={editing.cwd}
136139 environmentId={editing.environmentId}
140 threadRef={editing.threadRef}
137141 />
138142 {editing.canEdit(comment) ? (
139143 <Button
......@@ -423,6 +427,7 @@ const COMMENT_PAGE = 30;
423427
424428export function PullRequestSummaryTab({
425429 environmentId,
430 threadRef,
426431 reference,
427432 detail,
428433 activityPending,
......@@ -436,6 +441,7 @@ export function PullRequestSummaryTab({
436441 onRefresh,
437442}: {
438443 environmentId: EnvironmentId;
444 threadRef: ScopedThreadRef | null;
439445 reference: PullRequestRef;
440446 detail: PullRequestDetailView;
441447 activityPending: boolean;
......@@ -508,8 +514,12 @@ export function PullRequestSummaryTab({
508514 ),
509515 );
510516
517 const openLink = useOpenLink(threadRef);
511518 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 });
513523 };
514524
515525 const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false });
......@@ -547,6 +557,7 @@ export function PullRequestSummaryTab({
547557 const commentEditing: CommentEditing = {
548558 cwd: detail.workspaceRoot,
549559 environmentId,
560 threadRef,
550561 canEdit: (comment) => canEditPullRequestComment(detail, comment),
551562 editingId: editingCommentId,
552563 saving: commentSaving,
......@@ -722,6 +733,7 @@ export function PullRequestSummaryTab({
722733 value={detail.body}
723734 cwd={detail.workspaceRoot}
724735 environmentId={environmentId}
736 threadRef={threadRef}
725737 label="Pull request description"
726738 placeholder="Describe this pull request"
727739 saving={bodySaving}
......@@ -735,6 +747,7 @@ export function PullRequestSummaryTab({
735747 text={detail.body.trim().length > 0 ? detail.body : "_No description provided._"}
736748 cwd={detail.workspaceRoot}
737749 environmentId={environmentId}
750 threadRef={threadRef}
738751 />
739752 {canEditPullRequestChangeRequest(detail) ? (
740753 <Button
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+17-1
......@@ -4,6 +4,7 @@ import type {
44 PullRequestComment,
55 PullRequestDetailView,
66 PullRequestRef,
7 ScopedThreadRef,
78} from "@t3tools/contracts";
89import {
910 ChevronDownIcon,
......@@ -55,6 +56,8 @@ import {
5556interface ReactionSurface {
5657 readonly canReact: boolean;
5758 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;
5861 readonly reference: PullRequestRef;
5962 readonly onRefresh: () => void;
6063}
......@@ -64,16 +67,23 @@ function TimelineBody({
6467 markdown,
6568 cwd,
6669 environmentId,
70 threadRef,
6771}: {
6872 body: string;
6973 markdown: boolean;
7074 cwd: string;
7175 environmentId: EnvironmentId;
76 threadRef: ScopedThreadRef | null;
7277}) {
7378 return (
7479 <div className="mt-3">
7580 {markdown ? (
76 <PullRequestMarkdown text={body} cwd={cwd} environmentId={environmentId} />
81 <PullRequestMarkdown
82 text={body}
83 cwd={cwd}
84 environmentId={environmentId}
85 threadRef={threadRef}
86 />
7787 ) : (
7888 <p className="whitespace-pre-wrap text-xs text-muted-foreground">{body}</p>
7989 )}
......@@ -246,6 +256,7 @@ function ConversationCard({
246256 value={editable.body}
247257 cwd={cwd}
248258 environmentId={reactions.environmentId}
259 threadRef={reactions.threadRef}
249260 label="Edit comment"
250261 saving={saving}
251262 onSave={(body) => void save(body)}
......@@ -259,6 +270,7 @@ function ConversationCard({
259270 markdown={event.markdown}
260271 cwd={cwd}
261272 environmentId={reactions.environmentId}
273 threadRef={reactions.threadRef}
262274 />
263275 </div>
264276 ) : null}
......@@ -526,6 +538,7 @@ function ReviewVerdictEvent({
526538 markdown={event.markdown}
527539 cwd={cwd}
528540 environmentId={reactions.environmentId}
541 threadRef={reactions.threadRef}
529542 />
530543 ) : null}
531544 </div>
......@@ -538,6 +551,7 @@ function ReviewVerdictEvent({
538551export function PullRequestTimelineTab({
539552 detail,
540553 environmentId,
554 threadRef = null,
541555 reference,
542556 order,
543557 onOpenCommit,
......@@ -545,6 +559,7 @@ export function PullRequestTimelineTab({
545559}: {
546560 detail: PullRequestDetailView;
547561 environmentId: EnvironmentId;
562 threadRef?: ScopedThreadRef | null;
548563 reference: PullRequestRef;
549564 order: "newest" | "oldest";
550565 onOpenCommit: (oid: string) => void;
......@@ -555,6 +570,7 @@ export function PullRequestTimelineTab({
555570 const reactions: ReactionSurface = {
556571 canReact: detail.capabilities.reactions === true,
557572 environmentId,
573 threadRef,
558574 reference,
559575 onRefresh,
560576 };
apps/web/src/components/settings/IntegrationsSettings.tsx+50
......@@ -8,12 +8,14 @@
88 */
99import {
1010 BROWSER_PROFILE_MAX_COUNT,
11 type BrowserLinkTarget,
1112 type BrowserProfile,
1213 type EnvironmentId,
1314 BROWSER_PROFILE_NAME_MAX_LENGTH,
1415 BROWSER_RECORDING_FRAME_RATES,
1516 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
1617 DEFAULT_BROWSER_PROFILE_ID,
18 DEFAULT_BROWSER_LINK_TARGET,
1719 DEFAULT_BROWSER_RECORDING_FRAME_RATE,
1820 DEFAULT_BROWSER_VIEWPORT,
1921 DEFAULT_PREVIEW_APPEARANCE,
......@@ -465,6 +467,53 @@ function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boo
465467 );
466468}
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
468517function AgentBrowserAccessSetting() {
469518 const settings = usePrimarySettings();
470519 const updateSettings = useUpdatePrimarySettings();
......@@ -868,6 +917,7 @@ export function IntegrationsSettingsPanel() {
868917 <BrowserZoomSetting disabled={previewDefaultsDisabled} />
869918 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />
870919 <BrowserRecordingFrameRateSetting disabled={previewDefaultsDisabled} />
920 <BrowserLinkTargetSetting disabled={previewDefaultsDisabled} />
871921 <BrowserAutoShowFloatingPreviewSetting disabled={previewDefaultsDisabled} />
872922 </>
873923 );
apps/web/src/components/settings/SettingsPanels.logic.test.ts+2
......@@ -269,6 +269,7 @@ describe("getChangedBrowserSettingLabels", () => {
269269 browserDefaultZoomFactor: 1.5,
270270 browserDefaultAppearance: "dark",
271271 browserRecordingFrameRate: 60,
272 browserLinkTarget: "app",
272273 browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
273274 }),
274275 ).toEqual([
......@@ -276,6 +277,7 @@ describe("getChangedBrowserSettingLabels", () => {
276277 "Browser zoom",
277278 "Browser appearance",
278279 "Recording frame rate",
280 "Open links in",
279281 "Floating preview",
280282 ]);
281283 });
apps/web/src/components/settings/SettingsPanels.logic.ts+4
......@@ -115,6 +115,7 @@ export type BrowserDefaultSettings = Pick<
115115 | "browserDefaultZoomFactor"
116116 | "browserDefaultAppearance"
117117 | "browserRecordingFrameRate"
118 | "browserLinkTarget"
118119 | "browserAutoShowFloatingPreview"
119120>;
120121
......@@ -155,6 +156,9 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings)
155156 ...(settings.browserRecordingFrameRate !== DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate
156157 ? ["Recording frame rate"]
157158 : []),
159 ...(settings.browserLinkTarget !== DEFAULT_UNIFIED_SETTINGS.browserLinkTarget
160 ? ["Open links in"]
161 : []),
158162 ...(settings.browserAutoShowFloatingPreview !==
159163 DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview
160164 ? ["Floating preview"]
apps/web/src/components/settings/SettingsPanels.tsx+2
......@@ -589,6 +589,7 @@ export function useSettingsRestore(onRestored?: () => void) {
589589 settings.browserDefaultZoomFactor,
590590 settings.browserDefaultAppearance,
591591 settings.browserRecordingFrameRate,
592 settings.browserLinkTarget,
592593 settings.browserAutoShowFloatingPreview,
593594 settings.appearanceContrast,
594595 settings.enableAgentBrowserAccess,
......@@ -735,6 +736,7 @@ export function useSettingsRestore(onRestored?: () => void) {
735736 browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor,
736737 browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance,
737738 browserRecordingFrameRate: DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate,
739 browserLinkTarget: DEFAULT_UNIFIED_SETTINGS.browserLinkTarget,
738740 browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
739741 // Re-granted like any other default. The confirmation dialog lists it by
740742 // 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", () => {
207207 });
208208 expect(result).not.toHaveProperty("targetId");
209209 });
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 });
210218});
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -364,6 +364,12 @@ export const SETTINGS_SEARCH_ITEMS = [
364364 title: "Browser recording frame rate",
365365 to: "/settings/integrations",
366366 },
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 },
367373 {
368374 id: "browser-auto-show-floating-preview",
369375 title: "Auto-show floating preview",
apps/web/src/lib/openPullRequestLink.ts+6-12
......@@ -11,8 +11,8 @@ import { type MouseEvent, useCallback } from "react";
1111
1212import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
1313
14import { useOpenLink } from "../browser/useOpenLink";
1415import { stackedThreadToast, toastManager } from "../components/ui/toast";
15import { readLocalApi } from "../localApi";
1616import { useRightPanelStore } from "../rightPanelStore";
1717import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
1818
......@@ -328,6 +328,7 @@ export function useOpenChangeRequestLink(
328328
329329export function useOpenPrLink(threadRef?: ScopedThreadRef) {
330330 const openChangeRequest = useOpenChangeRequestLink(threadRef);
331 const openLink = useOpenLink(threadRef);
331332 return useCallback(
332333 (event: MouseEvent<HTMLElement>, prUrl: string, targetThreadRef?: ScopedThreadRef) => {
333334 event.stopPropagation();
......@@ -342,16 +343,9 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) {
342343 event.preventDefault();
343344 if (!openInBrowser && openChangeRequest(event, prUrl, targetThreadRef)) return true;
344345
345 const api = readLocalApi();
346 if (!api) {
347 toastManager.add({
348 type: "error",
349 title: "Link opening is unavailable.",
350 });
351 return false;
352 }
353
354 void openPullRequestLink(api.shell, prUrl).catch((error) => {
346 // No project to show it in, so it is an ordinary link and follows the
347 // "Open links in" setting; the modifier still forces the system browser.
348 void openLink(prUrl, { event, threadRef: targetThreadRef }).catch((error: unknown) => {
355349 console.error(error);
356350 toastManager.add(
357351 stackedThreadToast({
......@@ -363,6 +357,6 @@ export function useOpenPrLink(threadRef?: ScopedThreadRef) {
363357 });
364358 return false;
365359 },
366 [openChangeRequest],
360 [openChangeRequest, openLink],
367361 );
368362}
packages/contracts/src/settings.ts+16
......@@ -188,6 +188,14 @@ export const BROWSER_RECORDING_FRAME_RATES = [30, 60] as const;
188188export const BrowserRecordingFrameRate = Schema.Literals(BROWSER_RECORDING_FRAME_RATES);
189189export type BrowserRecordingFrameRate = typeof BrowserRecordingFrameRate.Type;
190190export 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
192200export const ClientSettingsSchema = Schema.Struct({
193201 appearanceContrast: AppearanceContrast.pipe(
......@@ -210,6 +218,13 @@ export const ClientSettingsSchema = Schema.Struct({
210218 browserRecordingFrameRate: BrowserRecordingFrameRate.pipe(
211219 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_RECORDING_FRAME_RATE)),
212220 ),
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 ),
213228 /**
214229 * Whether an agent opening a preview pops the floating mini player into
215230 * view. Only applies when the agent didn't ask either way — an explicit
......@@ -1003,6 +1018,7 @@ export const ClientSettingsPatch = Schema.Struct({
10031018 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
10041019 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),
10051020 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),
1021 browserLinkTarget: Schema.optionalKey(BrowserLinkTarget),
10061022 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),
10071023 browserProfiles: Schema.optionalKey(Schema.Array(BrowserProfile)),
10081024 browserDefaultProfileId: Schema.optionalKey(BrowserProfileId),