authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-11 23:40:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 23:40:19-07:00
log5fe7568e871c0310c83fac0484fb84bba14d9148
tree94060c69541153a5067c174430693981da8c34b7
parent48595c787cf5ffe3fcc089ce36754d7c601e4007
signature Signed by PGP key B5690EEEBB952194

feat: open T3 Code from a link with a prompt ready to send (#18454)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

37 files changed, 2325 insertions(+), 39 deletions(-)

apps/desktop/src/app/DesktopClerk.test.ts+11-2
......@@ -396,7 +396,7 @@ it.effect.each(["startup", "open-url"] as const)(
396396 }).pipe(Effect.scoped),
397397);
398398
399it.effect("hands a web link to the renderer and leaves other links alone", () =>
399it.effect("hands web and app links to the renderer and leaves other links alone", () =>
400400 Effect.gen(function* () {
401401 storageMock.mockReturnValue(storageAdapter);
402402 createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });
......@@ -420,10 +420,19 @@ it.effect("hands a web link to the renderer and leaves other links alone", () =>
420420 // macOS hands the default browser every web link.
421421 assert.strictEqual(open("https://example.com/page"), 1);
422422 assert.strictEqual(open("http://localhost:3000/"), 1);
423 // Prompt and thread links ride the same queue; the renderer routes them in the app.
424 assert.strictEqual(open("t3code://new?prompt=hi"), 1);
425 assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1);
423426 // Anything else is not a web page; Electron keeps its own handling.
424427 assert.strictEqual(open("mailto:hello@example.com"), 0);
428 assert.strictEqual(open("t3code://somewhere-else"), 0);
425429 yield* Effect.yieldNow;
426 assert.deepStrictEqual(received, ["https://example.com/page", "http://localhost:3000/"]);
430 assert.deepStrictEqual(received, [
431 "https://example.com/page",
432 "http://localhost:3000/",
433 "t3code://new?prompt=hi",
434 "t3code://threads/env-1/thread-1",
435 ]);
427436 }).pipe(
428437 Effect.provide(layerDesktopClerk(true, [], "darwin")),
429438 Effect.provideService(HostProcess.Arguments, ["t3"]),
apps/desktop/src/app/DesktopClerk.ts+21-3
......@@ -12,6 +12,7 @@ import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/code
1212import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts";
1313import * as ElectronShell from "../electron/ElectronShell.ts";
1414import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";
15import { promptLinkAppPath } from "@t3tools/shared/promptLinks";
1516import * as HostProcess from "@t3tools/shared/HostProcess";
1617import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
1718import * as ElectronApp from "../electron/ElectronApp.ts";
......@@ -188,8 +189,15 @@ export const make = Effect.gen(function* () {
188189 );
189190 return true;
190191 };
192 // Prompt links and thread links (`t3code://new?…`, `t3code://threads/…`) reach the
193 // renderer through the same queue as web links; it routes them in the app.
194 const openAppLink = (value: string | undefined) => {
195 if (!value || promptLinkAppPath(value) === null) return false;
196 void runPromise(webLinks.receive(value));
197 return true;
198 };
191199 const args = yield* HostProcess.Arguments;
192 args.some((value) => startProviderAuthHandoff(value));
200 args.some((value) => startProviderAuthHandoff(value) || openAppLink(value));
193201 // As the default browser, macOS hands T3 Code every web link through the same event.
194202 const openWebLink = (url: string) => {
195203 if (!DesktopWebLinks.isWebLink(url)) return false;
......@@ -197,7 +205,12 @@ export const make = Effect.gen(function* () {
197205 return true;
198206 };
199207 yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => {
200 if (startProviderAuthHandoff(url) || resumeProviderAuth(url) || openWebLink(url))
208 if (
209 startProviderAuthHandoff(url) ||
210 resumeProviderAuth(url) ||
211 openWebLink(url) ||
212 openAppLink(url)
213 )
201214 event.preventDefault();
202215 });
203216 // A browser opens HTML files too, which macOS hands over by path.
......@@ -207,7 +220,12 @@ export const make = Effect.gen(function* () {
207220 void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href));
208221 });
209222 yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {
210 if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value)))
223 if (
224 argv?.some(
225 (value) =>
226 startProviderAuthHandoff(value) || resumeProviderAuth(value) || openAppLink(value),
227 )
228 )
211229 return;
212230 void runPromise(
213231 Effect.gen(function* () {
apps/desktop/src/app/DesktopWebLinks.ts+2-1
......@@ -18,7 +18,8 @@ export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path);
1818
1919/**
2020 * Web links and HTML files macOS opens with T3 Code once it is the default
21 * browser. Files arrive as file:// URLs. They can
21 * browser, plus `t3code://` prompt and thread links on every platform. Files
22 * arrive as file:// URLs. They can
2223 * arrive before the app is ready (the link that launched it) or while the web
2324 * app reloads, so they wait until the renderer says it is listening.
2425 */
apps/mobile/src/components/CopyTextButton.tsx+3
......@@ -10,6 +10,8 @@ export const CopyTextButton = memo(function CopyTextButton(props: {
1010 readonly accessibilityLabel: string;
1111 readonly text: string;
1212 readonly onCopy?: () => Promise<void>;
13 /** Lets a long-press menu wrap the button (`ControlPillMenu` injects it on Android). */
14 readonly onLongPress?: () => void;
1315 readonly tintColor?: ColorValue;
1416 readonly copiedTintColor?: ColorValue;
1517 readonly backgroundColor?: ColorValue;
......@@ -35,6 +37,7 @@ export const CopyTextButton = memo(function CopyTextButton(props: {
3537 accessibilityLabel={copied ? "Copied" : props.accessibilityLabel}
3638 disabled={props.text.length === 0}
3739 hitSlop={8}
40 onLongPress={props.onLongPress}
3841 onPress={async () => {
3942 try {
4043 if (props.onCopy) await props.onCopy();
apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx+2
......@@ -7,6 +7,8 @@ type AddProjectDestinationRouteParams = {
77 readonly remoteUrl?: string | string[];
88 readonly repositoryTitle?: string | string[];
99 readonly repositoryName?: string | string[];
10 /** A prompt link's prompt, for the draft the clone opens. */
11 readonly prompt?: string | string[];
1012};
1113
1214export function AddProjectDestinationRoute({
apps/mobile/src/features/projects/AddProjectScreen.tsx+34-1
......@@ -85,6 +85,11 @@ import {
8585 useSavedRemoteConnections,
8686} from "../../state/use-remote-environment-registry";
8787import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic";
88import {
89 createNewTaskDraft,
90 setComposerDraftText,
91 waitForComposerDraftsLoaded,
92} from "../../state/use-composer-drafts";
8893
8994interface EnvironmentOption {
9095 readonly environmentId: EnvironmentId;
......@@ -676,13 +681,31 @@ export function AddProjectSourceScreen() {
676681
677682function openNewTaskDraft(
678683 navigation: { dispatch: (action: ReturnType<typeof CommonActions.reset>) => void },
679 params: { environmentId: EnvironmentId; projectId: ProjectId; title: string; cloning?: "1" },
684 params: {
685 environmentId: EnvironmentId;
686 projectId: ProjectId;
687 title: string;
688 cloning?: "1";
689 draftId?: string;
690 },
680691) {
681692 navigation.dispatch(
682693 CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }),
683694 );
684695}
685696
697/** A new task draft in the project, holding a prompt link's prompt. */
698async function draftWithPrompt(
699 environmentId: EnvironmentId,
700 projectId: ProjectId,
701 prompt: string,
702): Promise<string> {
703 await waitForComposerDraftsLoaded();
704 const draftId = createNewTaskDraft({ environmentId, projectId });
705 setComposerDraftText(draftId, prompt);
706 return draftId;
707}
708
686709function useCreateProject(environment: EnvironmentOption | null) {
687710 const navigation = useNavigation();
688711 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
......@@ -1302,6 +1325,7 @@ export function AddProjectDestinationScreen(props: {
13021325 readonly remoteUrl?: string | string[];
13031326 readonly repositoryTitle?: string | string[];
13041327 readonly repositoryName?: string | string[];
1328 readonly prompt?: string | string[];
13051329}) {
13061330 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
13071331 reportFailure: false,
......@@ -1323,6 +1347,7 @@ export function AddProjectDestinationScreen(props: {
13231347 const createProject = useCreateProject(environment);
13241348 const remoteUrl = stringParam(props.remoteUrl);
13251349 const repositoryTitle = stringParam(props.repositoryTitle);
1350 const prompt = stringParam(props.prompt);
13261351 // A lookup derives this from "owner/repo", a pasted clone URL from its own
13271352 // last segment. Older links without the param keep the browsed folder.
13281353 // Trim once here: the path input and the folder-list filter must compare the
......@@ -1394,6 +1419,13 @@ export function AddProjectDestinationScreen(props: {
13941419 projectId,
13951420 title,
13961421 cloning: "1",
1422 ...(prompt
1423 ? // Without its prompt the draft still opens on the cloned project.
1424 await draftWithPrompt(environment.environmentId, projectId, prompt).then(
1425 (draftId) => ({ draftId }),
1426 () => ({}),
1427 )
1428 : {}),
13971429 });
13981430 }
13991431 }
......@@ -1424,6 +1456,7 @@ export function AddProjectDestinationScreen(props: {
14241456 isSubmitting,
14251457 navigation,
14261458 pathInput,
1459 prompt,
14271460 remoteUrl,
14281461 startProjectClone,
14291462 ]);
apps/mobile/src/features/prompt-links/prompt-link-actions.ts created+50
......@@ -0,0 +1,50 @@
1import { buildNewThreadLink, type LinkedPrompt } from "@t3tools/shared/promptLinks";
2import { Alert } from "react-native";
3
4import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
5import {
6 getComposerDraftSnapshot,
7 setComposerDraftText,
8 waitForComposerDraftsLoaded,
9} from "../../state/use-composer-drafts";
10import { linkedThreadDraftAction } from "./prompt-link-model";
11
12/** Shows the one notice a prompt link owes the user, built with `promptLinkNotice`. */
13export function showPromptLinkNotice(notice: string): void {
14 Alert.alert("Prompt from a link", notice);
15}
16
17/**
18 * Puts a linked prompt into an existing thread's composer draft. An empty composer is filled;
19 * typed text is replaced only when the user says so. The notice rides in the same alert, so a
20 * link never stacks two dialogs.
21 */
22export async function applyLinkedPromptToThreadDraft(
23 draftKey: string,
24 prompt: LinkedPrompt,
25 notice: string,
26): Promise<void> {
27 try {
28 await waitForComposerDraftsLoaded();
29 } catch {
30 Alert.alert("Could not open link", "Drafts could not be loaded, so the prompt was not added.");
31 return;
32 }
33 const action = linkedThreadDraftAction(getComposerDraftSnapshot(draftKey).text, prompt.text);
34 if (action !== "ask") {
35 if (action === "fill") setComposerDraftText(draftKey, prompt.text);
36 showPromptLinkNotice(notice);
37 return;
38 }
39 Alert.alert("Linked prompt ready. Replace draft?", notice, [
40 { text: "Keep mine", style: "cancel" },
41 { text: "Replace", onPress: () => setComposerDraftText(draftKey, prompt.text) },
42 ]);
43}
44
45/** Copies a shareable link that opens a new task with this prompt. Used by sent user messages. */
46export async function copyPromptLink(prompt: string): Promise<void> {
47 if (!(await tryCopyTextWithHaptic(buildNewThreadLink({ prompt }), { target: "prompt link" }))) {
48 Alert.alert("Could not copy", "Try again.");
49 }
50}
apps/mobile/src/features/prompt-links/prompt-link-model.test.ts created+76
......@@ -0,0 +1,76 @@
1import { parseNewThreadLink } from "@t3tools/shared/promptLinks";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 latestThreadEnvironmentIds,
6 linkedThreadDraftAction,
7 promptLinkNotice,
8 promptLinkScratchEnvironmentId,
9 promptLinkSearchFromParams,
10} from "./prompt-link-model";
11
12describe("promptLinkSearchFromParams", () => {
13 it("round-trips decoded route params into the shared parser", () => {
14 const search = promptLinkSearchFromParams({
15 prompt: "Fix the bug & add tests = done?",
16 repo: ["pingdotgg/t3code", "ignored/second"],
17 provider: "codex",
18 screen: "NewTask",
19 });
20
21 expect(parseNewThreadLink(search ?? "")).toEqual({
22 ok: true,
23 value: {
24 kind: "new",
25 prompt: { text: "Fix the bug & add tests = done?", hiddenCharacters: 0 },
26 repository: { owner: "pingdotgg", name: "t3code" },
27 provider: "codex",
28 model: null,
29 },
30 });
31 });
32
33 it("is null for routes without a prompt link", () => {
34 expect(promptLinkSearchFromParams({ incomingShareId: "share-1" })).toBeNull();
35 expect(promptLinkSearchFromParams(undefined)).toBeNull();
36 });
37});
38
39describe("promptLinkNotice", () => {
40 it("replaces the plain notice with the hidden character warning", () => {
41 expect(promptLinkNotice({ hiddenCharacters: 0 })).toBe(
42 "This prompt came from a link. Read it before you send.",
43 );
44 expect(promptLinkNotice({ hiddenCharacters: 2, modelUnavailable: true })).toBe(
45 "This prompt had hidden characters, it may be malicious. They were removed. Read it before you send.\n\nThe requested model is not available, so your default is used.",
46 );
47 });
48});
49
50describe("linkedThreadDraftAction", () => {
51 it("fills an empty composer and asks before replacing typed text", () => {
52 expect(linkedThreadDraftAction(" \n", "Do it")).toBe("fill");
53 expect(linkedThreadDraftAction("Do it", "Do it")).toBe("keep");
54 expect(linkedThreadDraftAction("My notes", "Do it")).toBe("ask");
55 });
56});
57
58describe("promptLinkScratchEnvironmentId", () => {
59 it("prefers the most recently active machine that offers threads without a project", () => {
60 expect(promptLinkScratchEnvironmentId(["a", "b", "c"], ["c", "b"])).toBe("b");
61 expect(promptLinkScratchEnvironmentId(["a"], ["c"])).toBe("c");
62 expect(promptLinkScratchEnvironmentId(["a"], [])).toBeNull();
63 });
64});
65
66describe("latestThreadEnvironmentIds", () => {
67 it("orders machines by their latest thread", () => {
68 expect(
69 latestThreadEnvironmentIds([
70 { environmentId: "a", updatedAt: "2026-01-02T00:00:00.000Z" },
71 { environmentId: "b", updatedAt: "2026-01-03T00:00:00.000Z" },
72 { environmentId: "a", updatedAt: "2026-01-04T00:00:00.000Z" },
73 ]),
74 ).toEqual(["a", "b"]);
75 });
76});
apps/mobile/src/features/prompt-links/prompt-link-model.ts created+92
......@@ -0,0 +1,92 @@
1import type { PromptLinkRepository } from "@t3tools/shared/promptLinks";
2
3/** The query keys a prompt link adds to the `new` and thread routes. */
4export const PROMPT_LINK_PARAM_KEYS = ["prompt", "repo", "pr", "provider", "model"] as const;
5
6type RouteParams = Readonly<Record<string, unknown>> | undefined;
7
8/**
9 * Rebuilds the link's query from React Navigation route params, so the shared parser reads the
10 * same input on every surface. Null when the route carries no prompt link.
11 */
12export function promptLinkSearchFromParams(
13 params: RouteParams,
14 keys: ReadonlyArray<string> = PROMPT_LINK_PARAM_KEYS,
15): string | null {
16 const search = new URLSearchParams();
17 for (const key of keys) {
18 const raw = params?.[key];
19 const value = Array.isArray(raw) ? raw[0] : raw;
20 if (typeof value === "string") search.set(key, value);
21 }
22 return search.size > 0 ? search.toString() : null;
23}
24
25/** Route params that remove a handled prompt link, so it applies once. */
26export function clearedPromptLinkParams(keys: ReadonlyArray<string> = PROMPT_LINK_PARAM_KEYS) {
27 return Object.fromEntries(keys.map((key) => [key, undefined]));
28}
29
30/**
31 * The text of the one notice a prompt link shows. Every link says the prompt came from a link;
32 * hidden characters replace that line with a stronger warning.
33 */
34export function promptLinkNotice(input: {
35 readonly hiddenCharacters: number;
36 readonly modelUnavailable?: boolean;
37 readonly pullRequestUnavailable?: number | null;
38}): string {
39 return [
40 input.hiddenCharacters > 0
41 ? "This prompt had hidden characters, it may be malicious. They were removed. Read it before you send."
42 : "This prompt came from a link. Read it before you send.",
43 input.modelUnavailable
44 ? "The requested model is not available, so your default is used."
45 : null,
46 input.pullRequestUnavailable != null
47 ? `Pull request #${input.pullRequestUnavailable} could not be loaded, so this task starts from your default branch.`
48 : null,
49 ]
50 .filter((line) => line !== null)
51 .join("\n\n");
52}
53
54/** How a linked prompt lands in an existing thread composer. */
55export function linkedThreadDraftAction(currentText: string, prompt: string) {
56 if (currentText.trim().length === 0) return "fill" as const;
57 return currentText === prompt ? ("keep" as const) : ("ask" as const);
58}
59
60/**
61 * The machine a prompt without a project opens on: the most recently active one that offers
62 * threads without a project, else the first that does.
63 */
64export function promptLinkScratchEnvironmentId<Id extends string>(
65 environmentIdsMostRecentFirst: ReadonlyArray<Id>,
66 scratchEnvironmentIds: ReadonlyArray<Id>,
67): Id | null {
68 return (
69 environmentIdsMostRecentFirst.find((id) => scratchEnvironmentIds.includes(id)) ??
70 scratchEnvironmentIds[0] ??
71 null
72 );
73}
74
75/** Each environment once, by its most recently updated thread first. */
76export function latestThreadEnvironmentIds<Id extends string>(
77 threads: ReadonlyArray<{ readonly environmentId: Id; readonly updatedAt: string }>,
78): ReadonlyArray<Id> {
79 const latest = new Map<Id, string>();
80 for (const thread of threads) {
81 if (thread.updatedAt > (latest.get(thread.environmentId) ?? "")) {
82 latest.set(thread.environmentId, thread.updatedAt);
83 }
84 }
85 return Array.from(latest.keys()).sort((left, right) =>
86 (latest.get(right) ?? "").localeCompare(latest.get(left) ?? ""),
87 );
88}
89
90/** `owner/name`, as the user knows the repository. */
91export const promptLinkRepositoryName = (repository: PromptLinkRepository) =>
92 `${repository.owner}/${repository.name}`;
apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts created+313
......@@ -0,0 +1,313 @@
1import { StackActions, useNavigation } from "@react-navigation/native";
2import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
3import {
4 findPromptLinkProject,
5 findPromptLinkPullRequestThread,
6 resolvePromptLinkModelSelection,
7} from "@t3tools/client-runtime/prompt-links";
8import {
9 isAtomCommandInterrupted,
10 squashAtomCommandFailure,
11} from "@t3tools/client-runtime/state/runtime";
12import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
13import type { EnvironmentId } from "@t3tools/contracts";
14import {
15 parseNewThreadLink,
16 type LinkedPrompt,
17 type PromptLinkPullRequest,
18 type PromptLinkRepository,
19} from "@t3tools/shared/promptLinks";
20import { useEffect, useEffectEvent, useRef } from "react";
21import { Alert } from "react-native";
22
23import { scopedThreadKey } from "../../lib/scopedEntities";
24import { useServerConfigs, useThreadShells } from "../../state/entities";
25import { projectEnvironment } from "../../state/projects";
26import { composerPullRequests } from "../../state/pull-requests";
27import { useAtomCommand } from "../../state/use-atom-command";
28import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
29import {
30 createNewTaskDraft,
31 setComposerDraftText,
32 updateComposerDraftSettings,
33 waitForComposerDraftsLoaded,
34} from "../../state/use-composer-drafts";
35import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
36import { useWorkspaceState } from "../../state/workspace";
37import { useNewTaskFlow } from "../threads/new-task-flow-provider";
38import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions";
39import {
40 clearedPromptLinkParams,
41 promptLinkNotice,
42 promptLinkRepositoryName,
43 latestThreadEnvironmentIds,
44 promptLinkScratchEnvironmentId,
45 promptLinkSearchFromParams,
46} from "./prompt-link-model";
47
48/** What a `new` link asks for, in one shape for every destination. */
49type LinkedDraftRequest = {
50 readonly prompt: LinkedPrompt | null;
51 readonly provider: string | null;
52 readonly model: string | null;
53 readonly pullRequest: PromptLinkPullRequest | null;
54};
55
56/**
57 * Opens a `t3code://new?…` link. The link lands on the New Task picker with its query as route
58 * params; this resolves it to a project, a thread, or a choice, then fills a draft. Nothing is
59 * ever sent: the user reads the prompt and presses Send. Used by `NewTaskRouteScreen`.
60 */
61export function usePromptLinkResolver(params: Readonly<Record<string, unknown>> | undefined) {
62 const navigation = useNavigation();
63 const flow = useNewTaskFlow();
64 const { state: catalogState } = useWorkspaceState();
65 const threads = useThreadShells();
66 const serverConfigs = useServerConfigs();
67 const { connectedEnvironments } = useRemoteConnectionStatus();
68 const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
69 const loadPullRequest = useAtomQueryRunner(composerPullRequests.detail, {
70 reportFailure: false,
71 });
72 const search = promptLinkSearchFromParams(params);
73 // A cold launch from a link has no projects or threads yet. Matching then would offer to
74 // clone a repository the user already has.
75 const waitingForShells =
76 catalogState.isLoadingConnections ||
77 catalogState.hasPendingShellSnapshot ||
78 // A machine still connecting may hold the repository or thread. One that keeps failing to
79 // reconnect has settled offline, and must not hold every link.
80 catalogState.connectingEnvironments.some(
81 (environment) =>
82 environment.connectionState === "connecting" || environment.connectionError === null,
83 );
84
85 // Writes the prompt and the requested model into a draft. Returns true when the link named a
86 // model the user cannot use, so their default stands in.
87 const writeDraft = (
88 draftKey: string,
89 environmentId: EnvironmentId,
90 request: LinkedDraftRequest,
91 ): boolean => {
92 if (request.prompt) setComposerDraftText(draftKey, request.prompt.text);
93 if (request.provider === null) return false;
94 const resolved = resolvePromptLinkModelSelection(
95 serverConfigs.get(environmentId)?.providers ?? [],
96 { provider: request.provider, model: request.model },
97 );
98 if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection });
99 return resolved?.exact !== true;
100 };
101
102 const openProjectDraft = async (project: EnvironmentProject, request: LinkedDraftRequest) => {
103 try {
104 await waitForComposerDraftsLoaded();
105 } catch {
106 Alert.alert(
107 "Could not open link",
108 "Drafts could not be loaded, so the prompt was not added.",
109 );
110 return;
111 }
112 const draftKey = createNewTaskDraft({
113 environmentId: project.environmentId,
114 projectId: project.id,
115 });
116 let pullRequestUnavailable: number | null = null;
117 if (request.pullRequest) {
118 // The same draft the PR screen's Ask makes: Send checks out the PR head in a worktree.
119 const detail = await loadPullRequest({
120 environmentId: project.environmentId,
121 input: {
122 projectId: project.id,
123 repository: promptLinkRepositoryName(request.pullRequest),
124 number: request.pullRequest.number,
125 },
126 });
127 if (detail._tag === "Success") {
128 updateComposerDraftSettings(draftKey, {
129 workspaceSelection: {
130 mode: "worktree",
131 branch: detail.value.headBranch,
132 worktreePath: null,
133 startFromOrigin: false,
134 pullRequestReference: detail.value.url,
135 },
136 });
137 } else {
138 pullRequestUnavailable = request.pullRequest.number;
139 }
140 }
141 const modelUnavailable = writeDraft(draftKey, project.environmentId, request);
142 navigation.dispatch(
143 StackActions.push("NewTaskDraft", {
144 draftId: draftKey,
145 title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title,
146 }),
147 );
148 if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) {
149 showPromptLinkNotice(
150 promptLinkNotice({
151 hiddenCharacters: request.prompt?.hiddenCharacters ?? 0,
152 modelUnavailable,
153 pullRequestUnavailable,
154 }),
155 );
156 }
157 };
158
159 const openScratchDraft = async (request: LinkedDraftRequest) => {
160 const environmentId = promptLinkScratchEnvironmentId(
161 // Machines by their latest thread, scratch threads included.
162 latestThreadEnvironmentIds(threads),
163 connectedEnvironments
164 .filter(
165 (environment) =>
166 availableScratchWorkspaceRoot(
167 environment.connectionState,
168 serverConfigs.get(environment.environmentId),
169 ) !== null,
170 )
171 .map((environment) => environment.environmentId),
172 );
173 if (environmentId === null) {
174 Alert.alert(
175 "Could not start without a project",
176 "No connected environment offers threads without a project.",
177 );
178 return;
179 }
180 const result = await openScratch({ environmentId, input: {} });
181 if (result._tag === "Failure") {
182 if (!isAtomCommandInterrupted(result)) {
183 const error = squashAtomCommandFailure(result);
184 Alert.alert(
185 "Could not start without a project",
186 error instanceof Error
187 ? error.message
188 : "The folder for threads without a project could not be created.",
189 );
190 }
191 return;
192 }
193 await openProjectDraft(result.value, { ...request, pullRequest: null });
194 };
195
196 // Clone needs a tap on the destination screen, which then opens a draft in the cloned project
197 // holding the prompt. A pull request's prompt belongs on its branch, which only exists once the
198 // clone is done, so that link, like any on an older server, has to be opened again.
199 const cloneRepository = (repository: PromptLinkRepository, request: LinkedDraftRequest) => {
200 const environmentId =
201 flow.selectedEnvironmentId ?? connectedEnvironments[0]?.environmentId ?? null;
202 if (environmentId === null) {
203 Alert.alert("Could not clone", "Connect an environment first.");
204 return;
205 }
206 // Only a tracked clone opens a draft the prompt can ride into; older servers do not.
207 const prompt =
208 request.pullRequest === null &&
209 serverConfigs.get(environmentId)?.environment.capabilities.projectCloneTracking === true
210 ? request.prompt
211 : null;
212 const name = promptLinkRepositoryName(repository);
213 navigation.dispatch(
214 StackActions.push("AddProjectDestination", {
215 environmentId,
216 remoteUrl: `https://github.com/${name}.git`,
217 repositoryTitle: name,
218 repositoryName: repository.name,
219 ...(prompt ? { prompt: prompt.text } : {}),
220 }),
221 );
222 if (!request.prompt) return;
223 showPromptLinkNotice(
224 [
225 promptLinkNotice({
226 hiddenCharacters: request.prompt.hiddenCharacters,
227 modelUnavailable: prompt !== null && request.provider !== null,
228 }),
229 prompt
230 ? "The prompt opens in a new task once the clone starts."
231 : "Open the link again after the clone to get the prompt.",
232 ].join("\n\n"),
233 );
234 };
235
236 const resolveLink = useEffectEvent((linkSearch: string) => {
237 const parsed = parseNewThreadLink(linkSearch);
238 if (!parsed.ok) {
239 Alert.alert("Could not open link", parsed.error);
240 return;
241 }
242 const link = parsed.value;
243 const request: LinkedDraftRequest =
244 link.kind === "new"
245 ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null }
246 : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest };
247
248 if (link.kind === "pull-request") {
249 const thread = findPromptLinkPullRequestThread(threads, link.pullRequest);
250 if (thread) {
251 (navigation.getParent() ?? navigation).dispatch(
252 StackActions.replace("Thread", {
253 environmentId: String(thread.environmentId),
254 threadId: String(thread.id),
255 }),
256 );
257 if (link.prompt) {
258 void applyLinkedPromptToThreadDraft(
259 scopedThreadKey(thread.environmentId, thread.id),
260 link.prompt,
261 promptLinkNotice({ hiddenCharacters: link.prompt.hiddenCharacters }),
262 );
263 }
264 return;
265 }
266 }
267
268 const repository = link.kind === "new" ? link.repository : link.pullRequest;
269 if (repository === null) {
270 void openScratchDraft(request);
271 return;
272 }
273 // Most recently active first, so the user lands where they last worked. Scopes are ordered
274 // that way, but the checkouts inside one are not.
275 const lastActivity = new Map<string, string>();
276 for (const thread of threads) {
277 const key = `${thread.environmentId}:${thread.projectId}`;
278 if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt);
279 }
280 const activityOf = (project: EnvironmentProject) =>
281 lastActivity.get(`${project.environmentId}:${project.id}`) ?? "";
282 const project = findPromptLinkProject(
283 flow.projectScopes
284 .flatMap((scope) => scope.projects)
285 .sort((left, right) => activityOf(right).localeCompare(activityOf(left))),
286 repository,
287 );
288 if (project) {
289 void openProjectDraft(project, request);
290 return;
291 }
292 Alert.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [
293 { text: "Cancel", style: "cancel" },
294 ...(request.prompt
295 ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }]
296 : []),
297 { text: "Clone", onPress: () => cloneRepository(repository, request) },
298 ]);
299 });
300
301 // Handle each link once. Clearing the params lets the same link open again later.
302 const handledSearchRef = useRef<string | null>(null);
303 useEffect(() => {
304 if (search === null) {
305 handledSearchRef.current = null;
306 return;
307 }
308 if (waitingForShells || handledSearchRef.current === search) return;
309 handledSearchRef.current = search;
310 navigation.setParams(clearedPromptLinkParams());
311 resolveLink(search);
312 }, [navigation, search, waitingForShells]);
313}
apps/mobile/src/features/prompt-links/useThreadPromptLink.ts created+46
......@@ -0,0 +1,46 @@
1import { useNavigation } from "@react-navigation/native";
2import { parseThreadLinkPrompt } from "@t3tools/shared/promptLinks";
3import { useEffect, useRef } from "react";
4import { Alert } from "react-native";
5
6import { applyLinkedPromptToThreadDraft } from "./prompt-link-actions";
7import {
8 clearedPromptLinkParams,
9 promptLinkNotice,
10 promptLinkSearchFromParams,
11} from "./prompt-link-model";
12
13const THREAD_LINK_KEYS = ["prompt"];
14
15/**
16 * Applies the `prompt` of a `t3code://threads/<environmentId>/<threadId>?prompt=…` link to that
17 * thread's composer draft, once per link. Used by `ThreadRouteScreen`.
18 */
19export function useThreadPromptLink(
20 params: Readonly<Record<string, unknown>> | undefined,
21 threadDraftKey: string | null,
22) {
23 const navigation = useNavigation();
24 const search = promptLinkSearchFromParams(params, THREAD_LINK_KEYS);
25 const handledSearchRef = useRef<string | null>(null);
26 useEffect(() => {
27 if (search === null) {
28 handledSearchRef.current = null;
29 return;
30 }
31 if (threadDraftKey === null || handledSearchRef.current === search) return;
32 handledSearchRef.current = search;
33 navigation.setParams(clearedPromptLinkParams(THREAD_LINK_KEYS));
34 const parsed = parseThreadLinkPrompt(search);
35 if (!parsed.ok) {
36 Alert.alert("Could not open link", parsed.error);
37 return;
38 }
39 if (parsed.value === null) return;
40 void applyLinkedPromptToThreadDraft(
41 threadDraftKey,
42 parsed.value,
43 promptLinkNotice({ hiddenCharacters: parsed.value.hiddenCharacters }),
44 );
45 }, [navigation, search, threadDraftKey]);
46}
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+8
......@@ -31,12 +31,19 @@ import { useRemoteConnectionStatus } from "../../state/use-remote-environment-re
3131import type { WorkspaceState } from "../../state/workspaceModel";
3232import { useWorkspaceState } from "../../state/workspace";
3333import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
34import { usePromptLinkResolver } from "../prompt-links/usePromptLinkResolver";
3435import { useIncomingShare } from "../sharing/IncomingShareProvider";
3536import { useNewTaskFlow } from "./new-task-flow-provider";
3637import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection";
3738
3839type NewTaskRouteParams = {
3940 readonly incomingShareId?: string | string[];
41 // A `t3code://new?…` prompt link. See usePromptLinkResolver.
42 readonly prompt?: string | string[];
43 readonly repo?: string | string[];
44 readonly pr?: string | string[];
45 readonly provider?: string | string[];
46 readonly model?: string | string[];
4047};
4148
4249function deriveProjectEmptyState(catalogState: WorkspaceState): {
......@@ -144,6 +151,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
144151 const isFocused = useIsFocused();
145152 const insets = useSafeAreaInsets();
146153 const { getShare, releaseShareReservation } = useIncomingShare();
154 usePromptLinkResolver(route.params);
147155 const routeShareId = Array.isArray(route.params?.incomingShareId)
148156 ? route.params.incomingShareId[0]
149157 : route.params?.incomingShareId;
apps/mobile/src/features/threads/ThreadFeed.tsx+43-4
......@@ -31,6 +31,7 @@ import {
3131import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
3232import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
3333import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
34import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks";
3435import {
3536 parseComposerContextHref,
3637 collectComposerContextReferences,
......@@ -133,6 +134,8 @@ import {
133134 type MediaVideoPreviewSource,
134135} from "../../lib/videoPreviewSource";
135136import { CopyTextButton } from "../../components/CopyTextButton";
137import { ControlPillMenu } from "../../components/ControlPill";
138import { copyPromptLink } from "../prompt-links/prompt-link-actions";
136139import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection";
137140import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";
138141import {
......@@ -319,6 +322,45 @@ async function waitForThreadShell(
319322 });
320323}
321324
325const USER_MESSAGE_COPY_MENU_ACTIONS = [
326 { id: "copy-prompt-link", title: "Copy prompt link", image: "link" },
327];
328
329/**
330 * The copy button under a sent user message. Long-press copies a link that opens a new task
331 * with this prompt, unless the prompt is longer than a link can carry.
332 */
333function UserMessageCopyButton(props: {
334 readonly text: string;
335 readonly onCopy: (() => Promise<void>) | undefined;
336 readonly tintColor: ColorValue;
337}) {
338 const button = (
339 <CopyTextButton
340 accessibilityLabel="Copy message"
341 text={props.text}
342 onCopy={props.onCopy}
343 tintColor={props.tintColor}
344 buttonSize={28}
345 iconSize={13}
346 />
347 );
348 // A link carries plain text, so context chips become their labels.
349 const linkText = replaceComposerContextReferences(props.text, (reference) => reference.label);
350 if (linkText.length > PROMPT_LINK_MAX_PROMPT_LENGTH) return button;
351 return (
352 <ControlPillMenu
353 actions={USER_MESSAGE_COPY_MENU_ACTIONS}
354 onPressAction={({ nativeEvent }) => {
355 if (nativeEvent.event === "copy-prompt-link") void copyPromptLink(linkText);
356 }}
357 shouldOpenOnLongPress
358 >
359 {button}
360 </ControlPillMenu>
361 );
362}
363
322364function AssistantForkButton(props: {
323365 readonly environmentId: EnvironmentId;
324366 readonly iconColor: ColorValue;
......@@ -1310,8 +1352,7 @@ function renderFeedEntry(
13101352 </Pressable>
13111353 ) : null}
13121354 {presentation.text.trim().length > 0 ? (
1313 <CopyTextButton
1314 accessibilityLabel="Copy message"
1355 <UserMessageCopyButton
13151356 text={presentation.text}
13161357 onCopy={
13171358 message.context
......@@ -1324,8 +1365,6 @@ function renderFeedEntry(
13241365 : undefined
13251366 }
13261367 tintColor={iconSubtleColor}
1327 buttonSize={28}
1328 iconSize={13}
13291368 />
13301369 ) : null}
13311370 </View>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+4
......@@ -86,6 +86,7 @@ import {
8686 type ThreadInspectorMode,
8787} from "./thread-inspector-content-stack";
8888import { threadRouteIsHydrating } from "./thread-route-hydration";
89import { useThreadPromptLink } from "../prompt-links/useThreadPromptLink";
8990import { ThreadHeader } from "./ThreadHeader";
9091import {
9192 useAndroidWorkspaceBack,
......@@ -151,6 +152,8 @@ function OpeningThreadLoadingScreen() {
151152type ThreadRouteScreenRouteProps = StaticScreenProps<{
152153 readonly environmentId: string;
153154 readonly threadId: string;
155 /** A prompt link's draft text. See useThreadPromptLink. */
156 readonly prompt?: string | string[];
154157}>;
155158
156159interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {
......@@ -210,6 +213,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
210213 ? null
211214 : scopedThreadKey(selectedThread.environmentId, selectedThread.id);
212215 const selectedThreadDetailState = useSelectedThreadDetailState();
216 useThreadPromptLink(params, routeThreadKey);
213217
214218 if (environmentId === null || threadIdRaw === null) {
215219 return <OpeningThreadLoadingScreen />;
apps/web/src/components/CommandPalette.tsx+59
......@@ -99,6 +99,11 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra
9999import { useHandleNewThread } from "../hooks/useHandleNewThread";
100100import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
101101import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
102import {
103 composerPromptLink,
104 copyPromptLink,
105 promptLinkRepositoryOf,
106} from "./promptLinks/copyPromptLink";
102107import { useClientSettings } from "../hooks/useSettings";
103108import { useTheme } from "../hooks/useTheme";
104109import { useCustomThemes } from "../hooks/useCustomThemes";
......@@ -641,6 +646,10 @@ function OpenCommandPaletteDialog(props: {
641646 const availableSettingsSearchItems = useAvailableSettingsSearchItems();
642647 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
643648 useHandleNewThread();
649 const paletteRouteTarget = useParams({
650 strict: false,
651 select: (params) => resolveThreadRouteTarget(params),
652 });
644653 const projects = useProjects();
645654 const referenceThreadRef =
646655 pathname === "/pull-requests"
......@@ -1861,6 +1870,56 @@ function OpenCommandPaletteDialog(props: {
18611870 });
18621871 }
18631872
1873 // Read once per open: the palette is a snapshot of the composer, not a live view of it.
1874 const promptLinkTarget = paletteRouteTarget
1875 ? paletteRouteTarget.kind === "server"
1876 ? paletteRouteTarget.threadRef
1877 : paletteRouteTarget.draftId
1878 : null;
1879 const promptLinkProjectRef = activeThread ?? activeDraftThread;
1880 const promptLinkInput =
1881 promptLinkTarget === null
1882 ? null
1883 : {
1884 target: promptLinkTarget,
1885 project: projects.find(
1886 (project) =>
1887 project.environmentId === promptLinkProjectRef?.environmentId &&
1888 project.id === promptLinkProjectRef.projectId,
1889 ),
1890 providers:
1891 environments.find(
1892 (environment) => environment.environmentId === promptLinkProjectRef?.environmentId,
1893 )?.serverConfig?.providers ?? [],
1894 threadModelSelection: activeThread?.modelSelection ?? null,
1895 };
1896 const plainPromptLink = promptLinkInput
1897 ? composerPromptLink({ ...promptLinkInput, withContext: false })
1898 : null;
1899 if (promptLinkInput !== null && plainPromptLink !== null) {
1900 actionItems.push({
1901 kind: "action",
1902 value: "action:copy-prompt-link",
1903 searchTerms: ["copy", "share", "prompt", "link"],
1904 title: "Copy prompt link",
1905 icon: <LinkIcon className={ITEM_ICON_CLASS} />,
1906 run: () => copyPromptLink(plainPromptLink),
1907 });
1908 if (promptLinkRepositoryOf(promptLinkInput.project) !== null) {
1909 const contextLink = composerPromptLink({ ...promptLinkInput, withContext: true });
1910 if (contextLink !== null) {
1911 actionItems.push({
1912 kind: "action",
1913 value: "action:copy-prompt-link-with-context",
1914 searchTerms: ["copy", "share", "prompt", "link", "repository", "model"],
1915 title: "Copy prompt link with repo and model",
1916 icon: <LinkIcon className={ITEM_ICON_CLASS} />,
1917 run: () => copyPromptLink(contextLink),
1918 });
1919 }
1920 }
1921 }
1922
18641923 if (activeThreadReferenceCopyTarget !== null) {
18651924 actionItems.push({
18661925 kind: "action",
apps/web/src/components/chat/MessageCopyButton.tsx+8-3
......@@ -13,12 +13,17 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1313
1414export const MessageCopyButton = memo(function MessageCopyButton({
1515 text,
16 label = "Copy message",
17 icon = Copy,
1618 extraFlavors,
1719 size = "xs",
1820 variant = "outline",
1921 className,
2022}: {
2123 text: string;
24 label?: string;
25 /** The icon shown until the copy lands. */
26 icon?: typeof Copy;
2227 /** Additional clipboard types written beside `text/plain` when the platform allows it. */
2328 extraFlavors?: Readonly<Record<string, string>>;
2429 size?: "xs" | "icon-xs";
......@@ -38,7 +43,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({
3843 <TooltipTrigger
3944 render={
4045 <Button
41 aria-label="Copy message"
46 aria-label={label}
4247 disabled={isCopied}
4348 onClick={() => copyToClipboard(text)}
4449 ref={ref}
......@@ -51,11 +56,11 @@ export const MessageCopyButton = memo(function MessageCopyButton({
5156 >
5257 <MorphIcon
5358 className={cn("size-3", isCopied && "text-primary")}
54 icon={isCopied ? Check : Copy}
59 icon={isCopied ? Check : icon}
5560 />
5661 </TooltipTrigger>
5762 <TooltipPopup>
58 <p>Copy message</p>
63 <p>{label}</p>
5964 </TooltipPopup>
6065 </Tooltip>
6166 );
apps/web/src/components/chat/MessagesTimeline.tsx+17-9
......@@ -152,7 +152,7 @@ import {
152152 ZapIcon,
153153 RotateCcwIcon,
154154} from "lucide-react";
155import { ChevronDown, ChevronRight } from "lucide";
155import { ChevronDown, ChevronRight, Link as LinkIconNode } from "lucide";
156156import type {
157157 ComposerContextId,
158158 ComposerContextRecord,
......@@ -186,6 +186,7 @@ import {
186186 timelineContentOverflowsViewport,
187187} from "./timelineScrollAnchoring";
188188import { MessageCopyButton } from "./MessageCopyButton";
189import { promptLinkFor } from "../promptLinks/copyPromptLink";
189190import { PierreEntryIcon } from "./PierreEntryIcon";
190191import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon";
191192import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
......@@ -2326,6 +2327,12 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
23262327 .map((record) => record.contextId),
23272328 [resolvedContext.records],
23282329 );
2330 // Chips read as their labels outside a draft, so a shared link carries the prose alone.
2331 const plainText = useMemo(
2332 () => replaceComposerContextReferences(resolvedContext.text, (reference) => reference.label),
2333 [resolvedContext.text],
2334 );
2335 const promptLink = useMemo(() => promptLinkFor(plainText), [plainText]);
23292336 const contextClipboardFragment =
23302337 resolvedContext.records.length === 0
23312338 ? null
......@@ -2615,14 +2622,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
26152622 {resolvedContext.text && (
26162623 <MessageCopyButton
26172624 // Structured paste needs the canonical links to retain their positions.
2618 text={
2619 contextClipboardFragment
2620 ? resolvedContext.text
2621 : replaceComposerContextReferences(
2622 resolvedContext.text,
2623 (reference) => reference.label,
2624 )
2625 }
2625 text={contextClipboardFragment ? resolvedContext.text : plainText}
26262626 {...(contextClipboardFragment
26272627 ? {
26282628 extraFlavors: { [COMPOSER_CONTEXT_CLIPBOARD_MIME]: contextClipboardFragment },
......@@ -2631,6 +2631,14 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
26312631 variant="ghost"
26322632 />
26332633 )}
2634 {promptLink !== null && (
2635 <MessageCopyButton
2636 text={promptLink}
2637 label="Copy prompt link"
2638 icon={LinkIconNode}
2639 variant="ghost"
2640 />
2641 )}
26342642 </div>
26352643 </div>
26362644 </div>
apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx+11-3
......@@ -1,6 +1,7 @@
11import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
22import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
33import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
4import { promptLinkAppPath } from "@t3tools/shared/promptLinks";
45import { useRouter } from "@tanstack/react-router";
56import { useEffect, useEffectEvent, useRef } from "react";
67
......@@ -45,9 +46,11 @@ function threadShowingBrowser(
4546}
4647
4748/**
48 * Opens each web link or HTML file the OS hands T3 Code as the default browser
49 * (macOS). With a browser panel on screen the page opens as a new tab there;
50 * otherwise it starts a thread without a project, its browser panel maximized.
49 * Opens each link the OS hands T3 Code. Prompt and thread links, by `t3code://`
50 * or as hosted-app pages, open in the app. Other web links and HTML files arrive
51 * because T3 Code is the default browser (macOS): with a browser panel on screen
52 * the page opens as a new tab there; otherwise it starts a thread without a
53 * project, its browser panel maximized.
5154 */
5255export function DesktopWebLinkCoordinator() {
5356 const router = useRouter();
......@@ -93,6 +96,11 @@ export function DesktopWebLinkCoordinator() {
9396 };
9497
9598 const openLink = useEffectEvent(async (url: string) => {
99 const appPath = promptLinkAppPath(url);
100 if (appPath !== null) {
101 await router.navigate({ href: appPath });
102 return;
103 }
96104 const isFile = url.startsWith("file:");
97105 const routeParams = router.state.matches.at(-1)?.params ?? {};
98106 const threadRef =
apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx created+179
......@@ -0,0 +1,179 @@
1import {
2 type LinkedPrompt,
3 parseNewThreadLink,
4 parseThreadLinkPrompt,
5 promptLinkAppPath,
6 promptLinkDeepLink,
7} from "@t3tools/shared/promptLinks";
8import { useLocation } from "@tanstack/react-router";
9import * as Schema from "effect/Schema";
10import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
11
12import { APP_DISPLAY_NAME } from "~/branding";
13import { useLocalStorage } from "~/hooks/useLocalStorage";
14import { isHostedStaticApp } from "~/hostedPairing";
15import { useEnvironments } from "~/state/environments";
16
17import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
18import { Button } from "../ui/button";
19import { Checkbox } from "../ui/checkbox";
20import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page";
21
22const OPENER_STORAGE_KEY = "t3code:prompt-link-opener";
23const Opener = Schema.NullOr(Schema.Literals(["app", "browser"]));
24const DOWNLOAD_URL = "https://t3.codes/download";
25
26interface LinkSummary {
27 readonly prompt: LinkedPrompt | null;
28 readonly details: ReadonlyArray<string>;
29}
30
31function summarize(
32 appPath: string,
33): { ok: true; value: LinkSummary } | { ok: false; error: string } {
34 const url = new URL(appPath, window.location.origin);
35 if (url.pathname !== "/new") {
36 const prompt = parseThreadLinkPrompt(url.search);
37 return prompt.ok
38 ? { ok: true, value: { prompt: prompt.value, details: ["Opens one of your threads."] } }
39 : prompt;
40 }
41 const link = parseNewThreadLink(url.search);
42 if (!link.ok) return link;
43 if (link.value.kind === "pull-request") {
44 const { owner, name, number } = link.value.pullRequest;
45 return {
46 ok: true,
47 value: { prompt: link.value.prompt, details: [`Pull request ${owner}/${name}#${number}`] },
48 };
49 }
50 const { repository, provider, model } = link.value;
51 return {
52 ok: true,
53 value: {
54 prompt: link.value.prompt,
55 details: [
56 repository ? `Repository ${repository.owner}/${repository.name}` : "No project",
57 ...(provider ? [`Model ${model ? `${provider} ${model}` : provider}`] : []),
58 ],
59 },
60 };
61}
62
63/**
64 * On app.t3.codes a prompt link first asks where to open: the installed desktop or mobile app
65 * (through `t3code://`), this browser when it is connected to a machine, or the download page.
66 * Returns that page, or null when the link should open in this browser as usual.
67 */
68export function useHostedPromptLinkLanding(enabled: boolean): ReactNode | null {
69 const href = useLocation({ select: (location) => location.href });
70 const { environments, isReady } = useEnvironments();
71 const [opener, setOpener] = useLocalStorage(OPENER_STORAGE_KEY, null, Opener);
72 const [continuedHref, setContinuedHref] = useState<string | null>(null);
73 const appPath = useMemo(
74 () =>
75 enabled && isHostedStaticApp()
76 ? promptLinkAppPath(new URL(href, window.location.origin).href, window.location.origin)
77 : null,
78 [enabled, href],
79 );
80 if (appPath === null || continuedHref === href) return null;
81 const canContinue = isReady && environments.length > 0;
82 if (opener === "browser") {
83 if (canContinue) return null;
84 // Blank until the machines load, so a remembered choice does not flash this page.
85 if (!isReady) return <></>;
86 }
87 return (
88 <HostedPromptLinkLanding
89 appPath={appPath}
90 canContinue={canContinue}
91 openInAppRemembered={opener === "app"}
92 onChoose={(choice, remember) => {
93 if (remember) setOpener(choice);
94 if (choice === "browser") setContinuedHref(href);
95 }}
96 />
97 );
98}
99
100function HostedPromptLinkLanding({
101 appPath,
102 canContinue,
103 openInAppRemembered,
104 onChoose,
105}: {
106 readonly appPath: string;
107 readonly canContinue: boolean;
108 readonly openInAppRemembered: boolean;
109 readonly onChoose: (choice: "app" | "browser", remember: boolean) => void;
110}) {
111 const summary = useMemo(() => summarize(appPath), [appPath]);
112 const [remember, setRemember] = useState(false);
113 const deepLink = promptLinkDeepLink(appPath);
114 const onPhone = /android|iphone|ipad/i.test(navigator.userAgent);
115 const autoOpenedRef = useRef(false);
116
117 // After "remember", later links hand straight to the app. The page stays as the way back
118 // when the app is not installed on this device.
119 useEffect(() => {
120 if (!openInAppRemembered || !summary.ok || autoOpenedRef.current) return;
121 autoOpenedRef.current = true;
122 window.location.href = deepLink;
123 }, [deepLink, openInAppRemembered, summary.ok]);
124
125 if (!summary.ok) {
126 return (
127 <StandalonePage tone="error">
128 <StandalonePageHeader
129 eyebrow={APP_DISPLAY_NAME}
130 title="Could not open the link"
131 description={summary.error}
132 />
133 </StandalonePage>
134 );
135 }
136
137 const { prompt, details } = summary.value;
138 return (
139 <StandalonePage tone="brand">
140 <StandalonePageHeader
141 eyebrow={APP_DISPLAY_NAME}
142 title="Open this prompt"
143 description="It opens as a draft. Nothing runs until you press Send."
144 />
145 {prompt !== null && prompt.hiddenCharacters > 0 ? (
146 <Alert className="mt-5" variant="error">
147 <AlertTitle>This prompt had hidden characters, it may be malicious</AlertTitle>
148 <AlertDescription>
149 T3 Code removed {prompt.hiddenCharacters} hidden character
150 {prompt.hiddenCharacters === 1 ? "" : "s"}. Read the prompt before you send it.
151 </AlertDescription>
152 </Alert>
153 ) : null}
154 {prompt !== null ? (
155 <pre className="mt-5 max-h-72 overflow-auto rounded-lg border border-border bg-background p-3 text-sm whitespace-pre-wrap">
156 {prompt.text}
157 </pre>
158 ) : null}
159 <p className="mt-3 text-xs text-muted-foreground">{details.join(" · ")}</p>
160 <div className="mt-6 flex flex-wrap gap-2">
161 <Button render={<a href={deepLink} />} onClick={() => onChoose("app", remember)}>
162 {onPhone ? "Open in app" : "Open in desktop app"}
163 </Button>
164 {canContinue ? (
165 <Button variant="outline" onClick={() => onChoose("browser", remember)}>
166 Continue here
167 </Button>
168 ) : null}
169 <Button variant="ghost" render={<a href={DOWNLOAD_URL} />}>
170 Get T3 Code
171 </Button>
172 </div>
173 <label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
174 <Checkbox checked={remember} onCheckedChange={(checked) => setRemember(checked === true)} />
175 Remember my choice
176 </label>
177 </StandalonePage>
178 );
179}
apps/web/src/components/promptLinks/NewThreadLinkView.tsx created+379
......@@ -0,0 +1,379 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { getCloneDestinationPath } from "@t3tools/client-runtime/operations/projects";
3import {
4 findPromptLinkProject,
5 findPromptLinkPullRequestThread,
6 resolvePromptLinkModelSelection,
7} from "@t3tools/client-runtime/prompt-links";
8import {
9 isAtomCommandInterrupted,
10 squashAtomCommandFailure,
11} from "@t3tools/client-runtime/state/runtime";
12import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
13import {
14 type NewThreadLink,
15 type PromptLinkRepository,
16 promptLinkPullRequestUrl,
17} from "@t3tools/shared/promptLinks";
18import { useAtomValue } from "@effect/atom-react";
19import { useLocation, useNavigate } from "@tanstack/react-router";
20import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
21
22import { useComposerDraftStore } from "~/composerDraftStore";
23import { isElectron } from "~/env";
24import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
25import { useScratchProject } from "~/hooks/useScratchProject";
26import { newProjectId } from "~/lib/utils";
27import { useProjects, useThreadShells, waitForProject } from "~/state/entities";
28import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
29import { allReachableShellsLiveAtom } from "~/state/shell";
30import { sourceControlEnvironment } from "~/state/sourceControl";
31import { useAtomCommand } from "~/state/use-atom-command";
32
33import { usePullRequestHandoffs } from "../pullRequest/usePullRequestActions";
34import { sortScopedProjectsForSidebar } from "../Sidebar.logic";
35import { Button } from "../ui/button";
36import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty";
37import { SidebarInset } from "../ui/sidebar";
38import { stackedThreadToast, toastManager } from "../ui/toast";
39import { WorkspacePageHeader } from "../WorkspacePageHeader";
40import { placeLinkedPrompt, warnAboutHiddenCharacters } from "./linkedPrompt";
41
42type LinkState =
43 | { readonly status: "opening" }
44 | { readonly status: "opened" }
45 | { readonly status: "failed"; readonly message: string; readonly title?: string }
46 | { readonly status: "missing-repository"; readonly repository: PromptLinkRepository }
47 | {
48 readonly status: "pull-request-checkout";
49 readonly project: EnvironmentProject;
50 };
51
52// A link opens once per history entry. Going back to it lands here again, and opening a
53// second draft for the same click would surprise more than it helps.
54const openedLinkKeys = new Set<string>();
55
56/**
57 * The `/new` route: turns a prompt link into a draft holding its prompt, in the project the
58 * link names (or "No project"), or into the thread behind a linked pull request. The user
59 * still reads the prompt and presses Send.
60 */
61export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) {
62 const locationKey = useLocation({ select: (location) => location.state.__TSR_key ?? "" });
63 const navigate = useNavigate();
64 // The link needs every reachable machine's projects and threads to find its repository.
65 const ready = useAtomValue(allReachableShellsLiveAtom);
66 const projects = useProjects();
67 const threads = useThreadShells();
68 const { environments } = useEnvironments();
69 const primaryEnvironmentId = usePrimaryEnvironmentId();
70 const handleNewThread = useNewThreadHandler();
71 const { scratchEnvironmentId, openScratchProject } = useScratchProject();
72 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
73 reportFailure: false,
74 });
75 const [state, setState] = useState<LinkState>(() =>
76 openedLinkKeys.has(locationKey) ? { status: "opened" } : { status: "opening" },
77 );
78 const startedRef = useRef(false);
79
80 const recentProjects = () =>
81 sortScopedProjectsForSidebar(
82 projects.filter((project) => project.isScratch !== true),
83 threads,
84 "updated_at",
85 );
86
87 /** The machine the user worked on last, scratch threads included. */
88 const lastActiveEnvironmentId = () => {
89 let latest: (typeof threads)[number] | undefined;
90 for (const thread of threads) {
91 if (latest === undefined || thread.updatedAt > latest.updatedAt) latest = thread;
92 }
93 return latest?.environmentId ?? primaryEnvironmentId;
94 };
95
96 /** Opens a draft in the project with the link's prompt and, when it is available, model. */
97 const openDraft = async (project: EnvironmentProject, extraPrompt?: string) => {
98 const opened = await handleNewThread(scopeProjectRef(project.environmentId, project.id), {
99 replace: true,
100 }).catch(() => null);
101 if (opened === null) {
102 setState({ status: "failed", message: "T3 Code could not open a new thread." });
103 return;
104 }
105 setState({ status: "opened" });
106 const notes: Array<string> = [];
107 if (link.kind === "new" && link.provider !== null) {
108 const providers =
109 environments.find((environment) => environment.environmentId === project.environmentId)
110 ?.serverConfig?.providers ?? [];
111 const resolved = resolvePromptLinkModelSelection(providers, {
112 provider: link.provider,
113 model: link.model,
114 });
115 if (resolved !== null) {
116 useComposerDraftStore.getState().setModelSelection(opened.draftId, resolved.selection, {
117 explicit: true,
118 replaceOptions: true,
119 });
120 }
121 if (resolved === null || !resolved.exact) {
122 notes.push(
123 resolved === null
124 ? `The link asked for ${link.provider}, which is not set up here, so your default model is used.`
125 : `The link asked for ${link.model}, which ${link.provider} does not offer here, so its default model is used.`,
126 );
127 }
128 }
129 const text = [link.prompt?.text, extraPrompt].filter(Boolean).join("\n\n");
130 if (text.length === 0) return;
131 placeLinkedPrompt(
132 opened.draftId,
133 { text, hiddenCharacters: link.prompt?.hiddenCharacters ?? 0 },
134 notes,
135 );
136 };
137
138 /** "No project" on the machine the user last worked on. */
139 const openWithoutProject = async () => {
140 setState({ status: "opening" });
141 // The machine the user last worked on, or the only one that offers it.
142 const environmentId =
143 scratchEnvironmentId(lastActiveEnvironmentId()) ?? scratchEnvironmentId(null);
144 // Reports its own failure.
145 const project = environmentId === null ? null : await openScratchProject(environmentId);
146 if (project === null) {
147 setState({
148 status: "failed",
149 message: "None of your machines can start a thread without a project right now.",
150 });
151 return;
152 }
153 // Without its repository, a pull request link still says which one it meant.
154 await openDraft(
155 project,
156 link.kind === "pull-request" ? promptLinkPullRequestUrl(link.pullRequest) : undefined,
157 );
158 };
159
160 const cloneRepository = async (repository: PromptLinkRepository) => {
161 const environmentId = lastActiveEnvironmentId();
162 const environment = environments.find((entry) => entry.environmentId === environmentId);
163 const newProjectsRoot = environment?.serverConfig?.newProjectsRoot ?? null;
164 if (
165 environmentId === null ||
166 newProjectsRoot === null ||
167 environment?.serverConfig?.environment.capabilities.projectCloneTracking !== true
168 ) {
169 setState({
170 status: "failed",
171 message:
172 "This machine cannot clone from a link. Update T3 Code on it, or clone the repository from the command palette.",
173 });
174 return;
175 }
176 setState({ status: "opening" });
177 const projectId = newProjectId();
178 const result = await startProjectClone({
179 environmentId,
180 input: {
181 projectId,
182 title: repository.name,
183 createdAt: new Date().toISOString(),
184 remoteUrl: `https://github.com/${repository.owner}/${repository.name}.git`,
185 destinationPath: getCloneDestinationPath(newProjectsRoot, repository.name),
186 },
187 });
188 if (result._tag === "Failure") {
189 if (!isAtomCommandInterrupted(result)) {
190 const error = squashAtomCommandFailure(result);
191 toastManager.add(
192 stackedThreadToast({
193 type: "error",
194 title: "Clone failed",
195 description: error instanceof Error ? error.message : "An error occurred.",
196 }),
197 );
198 }
199 setState({ status: "missing-repository", repository });
200 return;
201 }
202 // The server adds the project at once and clones behind it. The draft shows the clone's
203 // progress and holds Send until the code is there.
204 const project = await waitForProject(scopeProjectRef(environmentId, projectId), 5_000).catch(
205 () => null,
206 );
207 if (project === null) {
208 setState({ status: "failed", message: "The clone started, but its project did not appear." });
209 return;
210 }
211 // A pull request checks out from the cloned repository, which is not there yet.
212 if (link.kind === "pull-request") {
213 setState({
214 status: "failed",
215 title: `Cloning ${repository.owner}/${repository.name}`,
216 message: "Open this link again when the clone finishes.",
217 });
218 return;
219 }
220 await openDraft(project);
221 };
222
223 const start = useEffectEvent(() => {
224 if (startedRef.current) return;
225 startedRef.current = true;
226 openedLinkKeys.add(locationKey);
227 if (link.kind === "pull-request") {
228 const thread = findPromptLinkPullRequestThread(threads, link.pullRequest);
229 if (thread !== undefined) {
230 setState({ status: "opened" });
231 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
232 if (link.prompt !== null) placeLinkedPrompt(threadRef, link.prompt);
233 void navigate({
234 to: "/$environmentId/$threadId",
235 params: { environmentId: thread.environmentId, threadId: thread.id },
236 replace: true,
237 });
238 return;
239 }
240 }
241 const repository = link.kind === "new" ? link.repository : link.pullRequest;
242 if (repository === null) {
243 void openWithoutProject();
244 return;
245 }
246 const project = findPromptLinkProject(recentProjects(), repository);
247 if (project === undefined) {
248 setState({ status: "missing-repository", repository });
249 return;
250 }
251 if (link.kind === "pull-request") {
252 setState({ status: "pull-request-checkout", project });
253 return;
254 }
255 void openDraft(project);
256 });
257
258 // Runs once per link, when the shells are in. Later shell updates must not open it again.
259 useEffect(() => {
260 if (ready && state.status === "opening") start();
261 }, [ready, state.status]);
262
263 switch (state.status) {
264 case "opening":
265 return <LinkPage title="Opening the link..." />;
266 case "opened":
267 return (
268 <LinkPage title="This link is open">
269 <Button
270 size="sm"
271 variant="outline"
272 onClick={() => {
273 openedLinkKeys.delete(locationKey);
274 startedRef.current = false;
275 setState({ status: "opening" });
276 }}
277 >
278 Open it again
279 </Button>
280 </LinkPage>
281 );
282 case "failed":
283 return (
284 <LinkPage title={state.title ?? "Could not open the link"} description={state.message} />
285 );
286 case "missing-repository": {
287 const name = `${state.repository.owner}/${state.repository.name}`;
288 return (
289 <LinkPage
290 title={`You don't have ${name}`}
291 description="Clone it to work on it here, or start without a project."
292 >
293 <Button size="sm" onClick={() => void cloneRepository(state.repository)}>
294 Clone {name}
295 </Button>
296 <Button size="sm" variant="outline" onClick={() => void openWithoutProject()}>
297 Use without project
298 </Button>
299 </LinkPage>
300 );
301 }
302 case "pull-request-checkout":
303 return link.kind === "pull-request" ? (
304 <PullRequestLinkCheckout link={link} project={state.project} />
305 ) : null;
306 }
307}
308
309/**
310 * Asks, then checks the linked pull request out into its own worktree and opens a thread there.
311 * The checkout runs the project's setup script on code from the pull request, so a link alone
312 * never starts it.
313 */
314function PullRequestLinkCheckout({
315 link,
316 project,
317}: {
318 readonly link: Extract<NewThreadLink, { kind: "pull-request" }>;
319 readonly project: EnvironmentProject;
320}) {
321 const { handoff, startHandoff } = usePullRequestHandoffs({
322 environmentId: project.environmentId,
323 detail: {
324 projectId: project.id,
325 workspaceRoot: project.workspaceRoot,
326 url: promptLinkPullRequestUrl(link.pullRequest),
327 },
328 });
329 const warnedRef = useRef(false);
330 useEffect(() => {
331 if (warnedRef.current || link.prompt === null) return;
332 warnedRef.current = true;
333 warnAboutHiddenCharacters(link.prompt);
334 }, [link.prompt]);
335 const { owner, name, number } = link.pullRequest;
336 return (
337 <LinkPage
338 title={`Check out ${owner}/${name}#${number}?`}
339 description="This puts the pull request's code in a new worktree and runs your project's setup script on it."
340 >
341 <Button
342 size="sm"
343 disabled={handoff !== null}
344 // The checkout reports its own progress and says the task is in the composer.
345 onClick={() =>
346 void startHandoff(
347 "prompt-link",
348 link.prompt === null ? null : { prompt: link.prompt.text },
349 )
350 }
351 >
352 {handoff !== null ? "Checking out..." : "Check out"}
353 </Button>
354 </LinkPage>
355 );
356}
357
358function LinkPage({
359 title,
360 description,
361 children,
362}: {
363 readonly title: string;
364 readonly description?: string;
365 readonly children?: ReactNode;
366}) {
367 return (
368 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
369 {isElectron ? <WorkspacePageHeader electron /> : null}
370 <Empty className="flex-1">
371 <EmptyHeader className="max-w-md">
372 <EmptyTitle>{title}</EmptyTitle>
373 {description ? <EmptyDescription>{description}</EmptyDescription> : null}
374 {children ? <div className="mt-5 flex justify-center gap-2">{children}</div> : null}
375 </EmptyHeader>
376 </Empty>
377 </SidebarInset>
378 );
379}
apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx created+50
......@@ -0,0 +1,50 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { parseThreadLinkPrompt, withoutPromptLinkParams } from "@t3tools/shared/promptLinks";
3import { useLocation, useNavigate } from "@tanstack/react-router";
4import { useEffect } from "react";
5
6import { stackedThreadToast, toastManager } from "../ui/toast";
7import { placeLinkedPrompt } from "./linkedPrompt";
8
9const handledLinkKeys = new Set<string>();
10
11/**
12 * Moves a thread link's `?prompt=` into that thread's composer, then drops it from the URL so a
13 * reload or a copied address does not bring it back. Rendered beside the thread view.
14 */
15export function ThreadLinkPrompt({ threadRef }: { readonly threadRef: ScopedThreadRef }) {
16 const navigate = useNavigate();
17 const location = useLocation({
18 select: (current) => ({
19 key: current.state.__TSR_key ?? current.href,
20 pathname: current.pathname,
21 search: current.searchStr,
22 }),
23 structuralSharing: true,
24 });
25
26 useEffect(() => {
27 if (!new URLSearchParams(location.search).has("prompt")) return;
28 if (!handledLinkKeys.has(location.key)) {
29 handledLinkKeys.add(location.key);
30 const parsed = parseThreadLinkPrompt(location.search);
31 if (!parsed.ok) {
32 toastManager.add(
33 stackedThreadToast({
34 type: "error",
35 title: "Could not open the linked prompt",
36 description: parsed.error,
37 }),
38 );
39 } else if (parsed.value !== null) {
40 placeLinkedPrompt(threadRef, parsed.value);
41 }
42 }
43 void navigate({
44 href: `${location.pathname}${withoutPromptLinkParams(location.search)}`,
45 replace: true,
46 });
47 }, [location, navigate, threadRef]);
48
49 return null;
50}
apps/web/src/components/promptLinks/copyPromptLink.ts created+88
......@@ -0,0 +1,88 @@
1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import {
3 type ModelSelection,
4 repositoryGroupingKeyOf,
5 type ServerProvider,
6} from "@t3tools/contracts";
7import {
8 buildNewThreadLink,
9 parsePromptLinkRepository,
10 PROMPT_LINK_MAX_PROMPT_LENGTH,
11 type PromptLinkRepository,
12} from "@t3tools/shared/promptLinks";
13
14import { type ComposerThreadTarget, useComposerDraftStore } from "~/composerDraftStore";
15import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
16import { configuredHostedAppUrl } from "~/hostedPairing";
17
18import { stackedThreadToast, toastManager } from "../ui/toast";
19
20/** A shareable link to a prompt on the hosted app, or null when the prompt cannot fit in one. */
21export function promptLinkFor(
22 prompt: string,
23 context?: {
24 readonly repository: PromptLinkRepository | null;
25 readonly provider: string | null;
26 readonly model: string | null;
27 },
28): string | null {
29 const trimmed = prompt.trim();
30 if (trimmed.length === 0 || trimmed.length > PROMPT_LINK_MAX_PROMPT_LENGTH) return null;
31 return buildNewThreadLink({ prompt: trimmed, ...context, origin: configuredHostedAppUrl() });
32}
33
34/** The project's GitHub repository, the only kind a prompt link can name. */
35export function promptLinkRepositoryOf(
36 project: Pick<EnvironmentProject, "repositoryIdentity"> | undefined,
37): PromptLinkRepository | null {
38 const identity = project?.repositoryIdentity;
39 if (identity == null) return null;
40 const key = repositoryGroupingKeyOf(identity);
41 return key.startsWith("github.com/")
42 ? parsePromptLinkRepository(key.slice("github.com/".length))
43 : null;
44}
45
46/**
47 * The draft in a composer as a link: its text and, with `withContext`, the project's repository
48 * and the model the composer has picked. Null when the composer holds no prompt to share.
49 */
50export function composerPromptLink(input: {
51 readonly target: ComposerThreadTarget;
52 readonly project: EnvironmentProject | undefined;
53 readonly providers: ReadonlyArray<ServerProvider>;
54 readonly threadModelSelection: ModelSelection | null;
55 readonly withContext: boolean;
56}): string | null {
57 const draft = useComposerDraftStore.getState().getComposerDraft(input.target);
58 if (!draft) return null;
59 if (!input.withContext) return promptLinkFor(draft.prompt);
60 const selection =
61 (draft.activeProvider ? draft.modelSelectionByProvider[draft.activeProvider] : undefined) ??
62 input.threadModelSelection;
63 const provider = selection
64 ? (input.providers.find((candidate) => candidate.instanceId === selection.instanceId)?.driver ??
65 null)
66 : null;
67 return promptLinkFor(draft.prompt, {
68 repository: promptLinkRepositoryOf(input.project),
69 provider,
70 model: provider === null ? null : (selection?.model ?? null),
71 });
72}
73
74/** Copies a prompt link and says so. */
75export async function copyPromptLink(link: string): Promise<void> {
76 try {
77 if (!(await writeTextToClipboard(link))) return;
78 toastManager.add({ type: "success", title: "Prompt link copied" });
79 } catch (error) {
80 toastManager.add(
81 stackedThreadToast({
82 type: "error",
83 title: "Could not copy the prompt link",
84 description: error instanceof Error ? error.message : "An error occurred.",
85 }),
86 );
87 }
88}
apps/web/src/components/promptLinks/linkedPrompt.ts created+56
......@@ -0,0 +1,56 @@
1import type { LinkedPrompt } from "@t3tools/shared/promptLinks";
2
3import { type ComposerThreadTarget, useComposerDraftStore } from "~/composerDraftStore";
4
5import { toastManager } from "../ui/toast";
6
7/** A warning that stays until dismissed when the link's prompt hid characters from the reader. */
8export function warnAboutHiddenCharacters(prompt: LinkedPrompt): void {
9 if (prompt.hiddenCharacters === 0) return;
10 toastManager.add({
11 type: "warning",
12 title: "This prompt had hidden characters, it may be malicious",
13 description: `T3 Code removed ${prompt.hiddenCharacters} hidden character${prompt.hiddenCharacters === 1 ? "" : "s"}. Read the prompt before you send it.`,
14 timeout: 0,
15 });
16}
17
18/**
19 * Puts a linked prompt in a composer for the user to read and send. Text the user already typed
20 * there is never replaced without asking: a toast offers to swap it for the linked prompt.
21 * `notes` carry anything the link asked for and did not get, such as a model.
22 */
23export function placeLinkedPrompt(
24 target: ComposerThreadTarget,
25 prompt: LinkedPrompt,
26 notes: ReadonlyArray<string> = [],
27): void {
28 warnAboutHiddenCharacters(prompt);
29 const place = () => {
30 useComposerDraftStore.getState().setPrompt(target, prompt.text);
31 // It was not written here, so it must be read before it is sent.
32 toastManager.add({
33 type: "info",
34 title: "Prompt added from a link",
35 description: ["Read it before you send.", ...notes].join(" "),
36 });
37 };
38 const existing = useComposerDraftStore.getState().getComposerDraft(target)?.prompt ?? "";
39 if (existing.trim().length === 0 || existing === prompt.text) {
40 place();
41 return;
42 }
43 const id = toastManager.add({
44 type: "info",
45 title: "Linked prompt ready",
46 description: "This thread already has a draft. Replace it with the linked prompt?",
47 timeout: 0,
48 actionProps: {
49 children: "Replace draft",
50 onClick: () => {
51 toastManager.close(id);
52 place();
53 },
54 },
55 });
56}
apps/web/src/hostedPairing.ts+2-1
......@@ -10,7 +10,8 @@ export interface HostedPairingRequest {
1010
1111export type HostedAppChannel = "latest" | "nightly";
1212
13function configuredHostedAppUrl(): string {
13/** The hosted app origin shared links point at. */
14export function configuredHostedAppUrl(): string {
1415 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
1516}
1617
apps/web/src/routeTree.gen.ts+21
......@@ -33,6 +33,7 @@ import { Route as SettingsArchivedRouteImport } from './routes/settings.archived
3333import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
3434import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey'
3535import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests'
36import { Route as ChatNewRouteImport } from './routes/_chat.new'
3637import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
3738import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
3839
......@@ -156,6 +157,11 @@ const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({
156157 path: '/pull-requests',
157158 getParentRoute: () => ChatRoute,
158159} as any)
160const ChatNewRoute = ChatNewRouteImport.update({
161 id: '/new',
162 path: '/new',
163 getParentRoute: () => ChatRoute,
164} as any)
159165const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
160166 id: '/draft/$draftId',
161167 path: '/draft/$draftId',
......@@ -176,6 +182,7 @@ export interface FileRoutesByFullPath {
176182 '/settings': typeof SettingsRouteWithChildren
177183 '/usage': typeof UsageRoute
178184 '/welcome': typeof WelcomeRoute
185 '/new': typeof ChatNewRoute
179186 '/pull-requests': typeof ChatPullRequestsRoute
180187 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
181188 '/settings/appearance': typeof SettingsAppearanceRoute
......@@ -202,6 +209,7 @@ export interface FileRoutesByTo {
202209 '/settings': typeof SettingsRouteWithChildren
203210 '/usage': typeof UsageRoute
204211 '/welcome': typeof WelcomeRoute
212 '/new': typeof ChatNewRoute
205213 '/pull-requests': typeof ChatPullRequestsRoute
206214 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
207215 '/settings/appearance': typeof SettingsAppearanceRoute
......@@ -231,6 +239,7 @@ export interface FileRoutesById {
231239 '/settings': typeof SettingsRouteWithChildren
232240 '/usage': typeof UsageRoute
233241 '/welcome': typeof WelcomeRoute
242 '/_chat/new': typeof ChatNewRoute
234243 '/_chat/pull-requests': typeof ChatPullRequestsRoute
235244 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
236245 '/settings/appearance': typeof SettingsAppearanceRoute
......@@ -261,6 +270,7 @@ export interface FileRouteTypes {
261270 | '/settings'
262271 | '/usage'
263272 | '/welcome'
273 | '/new'
264274 | '/pull-requests'
265275 | '/projects/$projectKey'
266276 | '/settings/appearance'
......@@ -287,6 +297,7 @@ export interface FileRouteTypes {
287297 | '/settings'
288298 | '/usage'
289299 | '/welcome'
300 | '/new'
290301 | '/pull-requests'
291302 | '/projects/$projectKey'
292303 | '/settings/appearance'
......@@ -315,6 +326,7 @@ export interface FileRouteTypes {
315326 | '/settings'
316327 | '/usage'
317328 | '/welcome'
329 | '/_chat/new'
318330 | '/_chat/pull-requests'
319331 | '/projects/$projectKey'
320332 | '/settings/appearance'
......@@ -517,6 +529,13 @@ declare module '@tanstack/react-router' {
517529 preLoaderRoute: typeof ChatPullRequestsRouteImport
518530 parentRoute: typeof ChatRoute
519531 }
532 '/_chat/new': {
533 id: '/_chat/new'
534 path: '/new'
535 fullPath: '/new'
536 preLoaderRoute: typeof ChatNewRouteImport
537 parentRoute: typeof ChatRoute
538 }
520539 '/_chat/draft/$draftId': {
521540 id: '/_chat/draft/$draftId'
522541 path: '/draft/$draftId'
......@@ -535,6 +554,7 @@ declare module '@tanstack/react-router' {
535554}
536555
537556interface ChatRouteChildren {
557 ChatNewRoute: typeof ChatNewRoute
538558 ChatPullRequestsRoute: typeof ChatPullRequestsRoute
539559 ChatIndexRoute: typeof ChatIndexRoute
540560 ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute
......@@ -542,6 +562,7 @@ interface ChatRouteChildren {
542562}
543563
544564const ChatRouteChildren: ChatRouteChildren = {
565 ChatNewRoute: ChatNewRoute,
545566 ChatPullRequestsRoute: ChatPullRequestsRoute,
546567 ChatIndexRoute: ChatIndexRoute,
547568 ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute,
apps/web/src/routes/__root.tsx+15
......@@ -67,6 +67,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme";
6767import { configureClientTracing } from "../observability/clientTracing";
6868import { resolveInitialServerAuthGateState } from "../environments/primary";
6969import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
70import { useHostedPromptLinkLanding } from "../components/promptLinks/HostedPromptLinkLanding";
7071import { isLocalEnvironmentDisabled } from "../localEnvironment";
7172import { useAtomValue } from "@effect/atom-react";
7273import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
......@@ -145,6 +146,9 @@ function RootRouteView() {
145146 const { authGateState } = Route.useRouteContext();
146147 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";
147148 const returningFromWelcomeRef = useRef(pathname === "/welcome");
149 const hostedPromptLinkLanding = useHostedPromptLinkLanding(
150 authGateState.status === "hosted-static",
151 );
148152
149153 useEffect(() => {
150154 if (pathname === "/welcome") {
......@@ -170,6 +174,17 @@ function RootRouteView() {
170174 );
171175 }
172176
177 // Ahead of first-run onboarding, which would otherwise send a new visitor to the welcome
178 // wizard and lose the link.
179 if (hostedPromptLinkLanding !== null) {
180 return (
181 <>
182 <DocumentTitleSync />
183 {hostedPromptLinkLanding}
184 </>
185 );
186 }
187
173188 // Show onboarding over the workspace, keeping automatic thread navigation
174189 // and other startup dialogs suspended until setup finishes.
175190 if (pathname === "/welcome") {
apps/web/src/routes/_chat.new.tsx created+30
......@@ -0,0 +1,30 @@
1import { parseNewThreadLink } from "@t3tools/shared/promptLinks";
2import { createFileRoute, useLocation } from "@tanstack/react-router";
3import { useMemo } from "react";
4
5import { NewThreadLinkView } from "../components/promptLinks/NewThreadLinkView";
6import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
7import { SidebarInset } from "../components/ui/sidebar";
8
9// The query is read raw: the router's own search parsing turns `prompt=123` into a number.
10function NewThreadLinkRoute() {
11 const search = useLocation({ select: (location) => location.searchStr });
12 const parsed = useMemo(() => parseNewThreadLink(search), [search]);
13 if (!parsed.ok) {
14 return (
15 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none">
16 <Empty className="flex-1">
17 <EmptyHeader className="max-w-md">
18 <EmptyTitle>Could not open the link</EmptyTitle>
19 <EmptyDescription>{parsed.error}</EmptyDescription>
20 </EmptyHeader>
21 </Empty>
22 </SidebarInset>
23 );
24 }
25 return <NewThreadLinkView key={search} link={parsed.value} />;
26}
27
28export const Route = createFileRoute("/_chat/new")({
29 component: NewThreadLinkRoute,
30});
apps/web/src/routes/_chat.tsx+4
......@@ -5,6 +5,7 @@ import { useEffect, useMemo } from "react";
55
66import { isCommandPaletteOpen } from "../commandPaletteBus";
77import { ThreadRouteView } from "../components/ThreadRouteView";
8import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt";
89import { resolveThreadRouteTarget } from "../threadRoutes";
910import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
1011import { openCommandPalette } from "../commandPaletteBus";
......@@ -224,6 +225,9 @@ function ChatRouteLayout() {
224225 return (
225226 <>
226227 <ChatRouteGlobalShortcuts />
228 {threadTarget?.kind === "server" ? (
229 <ThreadLinkPrompt threadRef={threadTarget.threadRef} />
230 ) : null}
227231 {threadTarget ? <ThreadRouteView target={threadTarget} /> : <Outlet />}
228232 </>
229233 );
apps/web/src/state/shell.ts+41-11
......@@ -49,6 +49,23 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => {
4949 return true;
5050}).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped"));
5151
52/**
53 * The persisted catalog can emit before platform discovery registers the primary environment.
54 * Neither that gap nor an empty catalog proves a project is absent.
55 */
56function catalogIsComplete(
57 catalog: EnvironmentCatalogState,
58 requiresPrimaryEnvironment: boolean,
59): boolean {
60 if (!catalog.isReady || catalog.entries.size === 0) return false;
61 return (
62 !requiresPrimaryEnvironment ||
63 Array.from(catalog.entries.values()).some(
64 (entry) => entry.target._tag === "PrimaryConnectionTarget",
65 )
66 );
67}
68
5269/** Cached or missing snapshots cannot establish that a saved project no longer exists. */
5370export function createAllEnvironmentProjectSnapshotsReadyAtom(input: {
5471 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
......@@ -57,17 +74,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: {
5774}) {
5875 return Atom.make((get) => {
5976 const catalog = get(input.catalogValueAtom);
60 // The persisted catalog can emit before platform discovery registers the
61 // primary environment. Neither that gap nor an empty catalog proves absence.
62 if (!catalog.isReady || catalog.entries.size === 0) return false;
63 if (
64 input.requiresPrimaryEnvironment &&
65 !Array.from(catalog.entries.values()).some(
66 (entry) => entry.target._tag === "PrimaryConnectionTarget",
67 )
68 ) {
69 return false;
70 }
77 if (!catalogIsComplete(catalog, input.requiresPrimaryEnvironment)) return false;
7178 for (const environmentId of enabledEnvironmentIds(catalog)) {
7279 const shell = get(input.shellStateValueAtom(environmentId));
7380 if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false;
......@@ -82,3 +89,26 @@ export const allEnvironmentProjectSnapshotsReadyAtom =
8289 shellStateValueAtom: environmentShell.stateValueAtom,
8390 requiresPrimaryEnvironment: !isHostedStaticApp(),
8491 });
92
93/**
94 * Every enabled machine has sent its live projects or has settled offline. Prompt links wait for
95 * it before they decide a repository is missing: one offline machine must not hold a link
96 * forever. A machine that has not started connecting yet is "available", not offline.
97 */
98export const allReachableShellsLiveAtom = Atom.make((get) => {
99 const catalog = get(environmentCatalog.catalogValueAtom);
100 if (!catalogIsComplete(catalog, !isHostedStaticApp())) return false;
101 for (const environmentId of enabledEnvironmentIds(catalog)) {
102 const shell = get(environmentShell.stateValueAtom(environmentId));
103 if (shell.status === "live" && Option.isSome(shell.snapshot)) continue;
104 const state = AsyncResult.value(get(environmentCatalog.stateAtom(environmentId)));
105 if (Option.isNone(state)) return false;
106 const connection = state.value;
107 const settledOffline =
108 connection.phase === "offline" ||
109 connection.phase === "blocked" ||
110 (connection.phase === "backoff" && connection.attempt > 2);
111 if (!settledOffline) return false;
112 }
113 return true;
114}).pipe(Atom.withLabel("web-all-reachable-shells-live"));
docs/user/default-browser.md+2-1
......@@ -8,6 +8,7 @@ page, so you can ask an agent about it right away.
88Choose **T3 Code** under **System Settings → Desktop & Dock → Default web browser**. To go back,
99choose another browser there.
1010
11HTML files you open from Finder open the same way.
11HTML files you open from Finder open the same way. [Prompt links](prompt-links.md) to
12app.t3.codes open in the app as a draft.
1213
1314A link that launches T3 Code waits until the app has connected to its environment, then opens.
docs/user/prompt-links.md created+56
......@@ -0,0 +1,56 @@
1# Prompt links
2
3A prompt link opens T3 Code with a prompt ready to send. Use one to share a prompt on social
4media, or to put a one-click fix in a pull request comment.
5
6A link never runs anything. It opens a draft. You read the prompt, then press Send. A link cannot
7change your permission mode, branch, or worktree. Your own defaults apply.
8
9## Make a link
10
11Open the command palette and choose **Copy prompt link**. It copies the draft in the composer.
12When the project is on GitHub, **Copy prompt link with repo and model** also adds the repository
13and the model you picked. Sent messages have a **Copy prompt link** button too.
14
15You can also write a link by hand:
16
17```
18https://app.t3.codes/new?prompt=Rate%20my%20swearing
19https://app.t3.codes/new?prompt=Fix%20the%20build&repo=owner/name&provider=codex&model=gpt-5.5
20https://app.t3.codes/new?pr=owner/name/123&prompt=Fix%20the%20failing%20check
21```
22
23| Parameter | Meaning |
24| ---------- | ----------------------------------------------------------------------------- |
25| `prompt` | The prompt. Required, except on pull request links. At most 8,000 characters. |
26| `repo` | A GitHub repository, as `owner/name` or its URL. |
27| `provider` | A provider such as `codex` or `claude`. |
28| `model` | A model of that provider, by its id or name. |
29| `pr` | A GitHub pull request, as `owner/name/123` or its URL. |
30
31Encode the prompt for a URL. In JavaScript, use `encodeURIComponent`.
32
33## Open a link
34
35On app.t3.codes, the link first asks where to open it: the desktop or mobile app, this browser
36when it is connected to a machine, or the download page. Select **Remember my choice** to skip
37this step next time.
38
39- **No `repo`**: the draft opens without a project, on the machine you used last.
40- **`repo`**: the draft opens in your most recently used project for that repository. A fork
41 counts only for its own owner. When you do not have the repository, choose **Clone** or **Use
42 without project**.
43- **`provider` and `model`**: used when you have that provider set up. Otherwise your default
44 model is used, and T3 Code tells you.
45- **`pr`**: opens the thread linked to that pull request on any connected machine, with the prompt
46 in its composer. When no thread is linked, the draft goes on the pull request branch in a new
47 worktree. T3 Code checks the code out only after you confirm, because the checkout runs your
48 setup script on it.
49
50A thread link with a prompt, such as `https://app.t3.codes/<environment>/<thread>?prompt=…`,
51puts the prompt in that thread's composer. If you already typed a draft there, T3 Code asks
52before it replaces it.
53
54T3 Code removes characters you cannot see, such as zero-width and hidden tag characters, because
55they can hide instructions for the agent. When a prompt had them, T3 Code warns you that it may be
56malicious.
packages/client-runtime/package.json+4
......@@ -39,6 +39,10 @@
3939 "types": "./src/userMessage.ts",
4040 "default": "./src/userMessage.ts"
4141 },
42 "./prompt-links": {
43 "types": "./src/promptLinks.ts",
44 "default": "./src/promptLinks.ts"
45 },
4246 "./secret-request": {
4347 "types": "./src/secretRequest.ts",
4448 "default": "./src/secretRequest.ts"
packages/client-runtime/src/promptLinks.test.ts created+114
......@@ -0,0 +1,114 @@
1import {
2 ProviderDriverKind,
3 ProviderInstanceId,
4 type RepositoryIdentity,
5 type ThreadPullRequestLink,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";
8
9import {
10 findPromptLinkProject,
11 findPromptLinkPullRequestThread,
12 resolvePromptLinkModelSelection,
13} from "./promptLinks.ts";
14
15const identity = (canonicalKey: string, origin?: string): RepositoryIdentity => ({
16 canonicalKey,
17 locator: { source: "git-remote", remoteName: "origin", remoteUrl: `https://${canonicalKey}` },
18 ...(origin ? { origin: { canonicalKey: origin } } : {}),
19});
20
21describe("findPromptLinkProject", () => {
22 it("takes the first exact match and never a fork of the repository", () => {
23 const projects = [
24 {
25 id: "fork",
26 repositoryIdentity: identity("github.com/pingdotgg/t3code", "github.com/me/t3code"),
27 },
28 { id: "none", repositoryIdentity: null },
29 { id: "recent", repositoryIdentity: identity("github.com/PingDotGG/T3Code") },
30 { id: "older", repositoryIdentity: identity("github.com/pingdotgg/t3code") },
31 ];
32 expect(findPromptLinkProject(projects, { owner: "pingdotgg", name: "t3code" })?.id).toBe(
33 "recent",
34 );
35 expect(findPromptLinkProject(projects, { owner: "me", name: "t3code" })?.id).toBe("fork");
36 expect(findPromptLinkProject(projects, { owner: "gitlab", name: "x" })).toBeUndefined();
37 });
38});
39
40describe("findPromptLinkPullRequestThread", () => {
41 const link = (number: number, source: ThreadPullRequestLink["source"] = "created") =>
42 ({
43 host: "github.com",
44 repository: "pingdotgg/t3code",
45 number,
46 url: `https://github.com/pingdotgg/t3code/pull/${number}`,
47 source,
48 linkedAt: "2026-01-01T00:00:00.000Z",
49 snapshot: null,
50 stack: null,
51 }) satisfies ThreadPullRequestLink;
52
53 it("finds the most recently updated thread linked to the pull request", () => {
54 const threads = [
55 { id: "old", updatedAt: "2026-01-01T00:00:00.000Z", pullRequests: [link(7)] },
56 { id: "new", updatedAt: "2026-02-01T00:00:00.000Z", pullRequests: [link(7)] },
57 { id: "other", updatedAt: "2026-03-01T00:00:00.000Z", pullRequests: [link(8)] },
58 {
59 id: "dismissed",
60 updatedAt: "2026-04-01T00:00:00.000Z",
61 pullRequests: [link(7, "stack-dismissed")],
62 },
63 ];
64 const pullRequest = { owner: "PingDotGG", name: "t3code", number: 7 };
65 expect(findPromptLinkPullRequestThread(threads, pullRequest)?.id).toBe("new");
66 expect(findPromptLinkPullRequestThread(threads, { ...pullRequest, number: 9 })).toBeUndefined();
67 });
68});
69
70describe("resolvePromptLinkModelSelection", () => {
71 const provider = (
72 instanceId: string,
73 driver: string,
74 status: "ready" | "warning" | "error" | "disabled" = "ready",
75 ) => ({
76 instanceId: ProviderInstanceId.make(instanceId),
77 driver: ProviderDriverKind.make(driver),
78 enabled: true,
79 status,
80 models: [
81 { slug: "small", name: "Small" },
82 { slug: "big", name: "Big", aliases: ["opus"], isDefault: true },
83 ],
84 });
85
86 it("maps the requested provider and model onto a usable instance", () => {
87 const providers = [provider("broken", "claudeAgent", "error"), provider("work", "claudeAgent")];
88 expect(
89 resolvePromptLinkModelSelection(providers, { provider: "claude", model: "small" }),
90 ).toEqual({
91 selection: { instanceId: "work", model: "small" },
92 exact: true,
93 });
94 expect(
95 resolvePromptLinkModelSelection(providers, { provider: "claudeagent", model: "opus" }),
96 ).toEqual({
97 selection: { instanceId: "work", model: "big" },
98 exact: true,
99 });
100 });
101
102 it("uses the instance default for an unknown model, and nothing for a missing provider", () => {
103 const providers = [provider("codex", "codex")];
104 expect(
105 resolvePromptLinkModelSelection(providers, { provider: "codex", model: "nope" }),
106 ).toEqual({
107 selection: { instanceId: "codex", model: "big" },
108 exact: false,
109 });
110 expect(
111 resolvePromptLinkModelSelection(providers, { provider: "cursor", model: null }),
112 ).toBeNull();
113 });
114});
packages/client-runtime/src/promptLinks.ts created+105
......@@ -0,0 +1,105 @@
1import {
2 repositoryGroupingKeyOf,
3 type ModelSelection,
4 type RepositoryIdentity,
5 type ServerProvider,
6 type ThreadLinkedPullRequest,
7 type ThreadPullRequestLink,
8} from "@t3tools/contracts";
9import { resolveSelectableModel, type SelectableModelOption } from "@t3tools/shared/model";
10import type { PromptLinkPullRequest, PromptLinkRepository } from "@t3tools/shared/promptLinks";
11import {
12 threadPullRequestKeyOf,
13 threadPullRequestsOf,
14 visibleThreadPullRequests,
15} from "@t3tools/shared/threadPullRequests";
16
17/**
18 * How web and mobile resolve a prompt link against what this client knows. Both read the same
19 * shells, so a link lands in the same project and thread on every surface.
20 */
21
22const repositoryKey = (repository: PromptLinkRepository) =>
23 `github.com/${repository.owner}/${repository.name}`.toLowerCase();
24
25/**
26 * The project a link's repository names. Callers pass projects most recently active first, so
27 * the user lands where they last worked. A fork only matches its own owner, not its upstream.
28 */
29export function findPromptLinkProject<
30 P extends { readonly repositoryIdentity?: RepositoryIdentity | null | undefined },
31>(projects: ReadonlyArray<P>, repository: PromptLinkRepository): P | undefined {
32 const key = repositoryKey(repository);
33 return projects.find(
34 (project) =>
35 project.repositoryIdentity != null &&
36 repositoryGroupingKeyOf(project.repositoryIdentity).toLowerCase() === key,
37 );
38}
39
40/** The most recently updated thread linked to the pull request, across every environment given. */
41export function findPromptLinkPullRequestThread<
42 T extends {
43 readonly updatedAt: string;
44 readonly pullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
45 readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
46 },
47>(threads: ReadonlyArray<T>, pullRequest: PromptLinkPullRequest): T | undefined {
48 const key = threadPullRequestKeyOf({
49 host: "github.com",
50 repository: `${pullRequest.owner}/${pullRequest.name}`,
51 number: pullRequest.number,
52 });
53 let latest: T | undefined;
54 for (const thread of threads) {
55 if (latest !== undefined && thread.updatedAt <= latest.updatedAt) continue;
56 const linked = visibleThreadPullRequests(threadPullRequestsOf(thread)).some(
57 (link) => threadPullRequestKeyOf(link) === key,
58 );
59 if (linked) latest = thread;
60 }
61 return latest;
62}
63
64// People write the name they know. Driver kinds are what the server reports.
65const PROVIDER_ALIASES: Record<string, string> = { claude: "claudeagent" };
66
67/**
68 * The selection a link asks for, on one of the user's own provider instances. `exact` is false
69 * when the instance exists but the model does not, so its default model stands in. Null when
70 * no usable instance of that provider exists; the user's default applies then.
71 */
72export function resolvePromptLinkModelSelection(
73 providers: ReadonlyArray<
74 Pick<ServerProvider, "instanceId" | "driver" | "enabled" | "availability" | "status"> & {
75 readonly models: ReadonlyArray<
76 SelectableModelOption & { readonly isDefault?: boolean | undefined }
77 >;
78 }
79 >,
80 request: { readonly provider: string; readonly model: string | null },
81): { readonly selection: ModelSelection; readonly exact: boolean } | null {
82 const driver = PROVIDER_ALIASES[request.provider] ?? request.provider;
83 const usable = providers.filter(
84 (provider) =>
85 provider.driver.toLowerCase() === driver &&
86 provider.enabled &&
87 provider.availability !== "unavailable",
88 );
89 const instance =
90 usable.find((provider) => provider.status === "ready") ??
91 usable.find((provider) => provider.status === "warning");
92 if (instance === undefined) return null;
93 const requested =
94 request.model === null
95 ? null
96 : resolveSelectableModel(instance.driver, request.model, instance.models);
97 const model =
98 requested ??
99 (instance.models.find((candidate) => candidate.isDefault) ?? instance.models[0])?.slug;
100 if (model === undefined) return null;
101 return {
102 selection: { instanceId: instance.instanceId, model },
103 exact: request.model === null || requested !== null,
104 };
105}
packages/shared/package.json+4
......@@ -251,6 +251,10 @@
251251 "types": "./src/assistantCitations.ts",
252252 "import": "./src/assistantCitations.ts"
253253 },
254 "./promptLinks": {
255 "types": "./src/promptLinks.ts",
256 "import": "./src/promptLinks.ts"
257 },
254258 "./threadLinks": {
255259 "types": "./src/threadLinks.ts",
256260 "import": "./src/threadLinks.ts"
packages/shared/src/promptLinks.test.ts created+126
......@@ -0,0 +1,126 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 buildNewThreadLink,
5 parseNewThreadLink,
6 parseThreadLinkPrompt,
7 promptLinkAppPath,
8 promptLinkDeepLink,
9 sanitizeLinkedPrompt,
10 withoutPromptLinkParams,
11} from "./promptLinks.ts";
12
13describe("sanitizeLinkedPrompt", () => {
14 it("removes characters a reader cannot see and counts them", () => {
15 const tagged = "rate me" + String.fromCodePoint(0xe0041, 0xe0042);
16 expect(sanitizeLinkedPrompt(`a\u200Bb\u202Ec\uFEFF${tagged}`)).toEqual({
17 text: "abcrate me",
18 hiddenCharacters: 5,
19 });
20 expect(sanitizeLinkedPrompt("a\u2028b\u2029c")).toEqual({
21 text: "a\nb\nc",
22 hiddenCharacters: 0,
23 });
24 });
25
26 it("keeps emoji sequences intact", () => {
27 const family = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}";
28 const heart = "\u2764\uFE0F";
29 expect(sanitizeLinkedPrompt(`${family} ${heart}`)).toEqual({
30 text: `${family} ${heart}`,
31 hiddenCharacters: 0,
32 });
33 expect(sanitizeLinkedPrompt("a\u200Db").hiddenCharacters).toBe(1);
34 expect(sanitizeLinkedPrompt("\u{1F468}\u200D").hiddenCharacters).toBe(1);
35 expect(sanitizeLinkedPrompt("\u{20000}\u200D\u{20001}").hiddenCharacters).toBe(1);
36 });
37});
38
39describe("parseNewThreadLink", () => {
40 it("reads a shared prompt with its optional repository and model", () => {
41 const result = parseNewThreadLink(
42 "?prompt=Rate+me&repo=https://github.com/pingdotgg/t3code.git&provider=Codex&model=gpt-5",
43 );
44 expect(result).toEqual({
45 ok: true,
46 value: {
47 kind: "new",
48 prompt: { text: "Rate me", hiddenCharacters: 0 },
49 repository: { owner: "pingdotgg", name: "t3code" },
50 provider: "codex",
51 model: "gpt-5",
52 },
53 });
54 });
55
56 it("reads a pull request link, whose prompt is optional", () => {
57 for (const pr of [
58 "pingdotgg/t3code/42",
59 "pingdotgg/t3code%2342",
60 "https://github.com/pingdotgg/t3code/pull/42/files",
61 ]) {
62 expect(parseNewThreadLink(`?pr=${pr}`)).toEqual({
63 ok: true,
64 value: {
65 kind: "pull-request",
66 pullRequest: { owner: "pingdotgg", name: "t3code", number: 42 },
67 prompt: null,
68 },
69 });
70 }
71 });
72
73 it("refuses links it cannot open safely", () => {
74 expect(parseNewThreadLink("?repo=a/b").ok).toBe(false);
75 expect(parseNewThreadLink("?prompt=%E2%80%8B").ok).toBe(false);
76 expect(parseThreadLinkPrompt("?prompt=%E2%80%8B").ok).toBe(false);
77 expect(parseNewThreadLink("?prompt=x&repo=../etc").ok).toBe(false);
78 expect(parseNewThreadLink(`?prompt=${"x".repeat(8_001)}`).ok).toBe(false);
79 });
80});
81
82describe("thread links", () => {
83 it("reads the prompt and leaves other parameters in place", () => {
84 expect(parseThreadLinkPrompt("?prompt=Fix+it")).toEqual({
85 ok: true,
86 value: { text: "Fix it", hiddenCharacters: 0 },
87 });
88 expect(parseThreadLinkPrompt("?panel=diff")).toEqual({ ok: true, value: null });
89 expect(withoutPromptLinkParams("?prompt=x&panel=diff")).toBe("?panel=diff");
90 });
91});
92
93describe("link round trips", () => {
94 it("builds a link that parses back to the same prompt", () => {
95 const link = buildNewThreadLink({
96 prompt: "Swear count & rating?",
97 repository: { owner: "pingdotgg", name: "t3code" },
98 provider: "claudeAgent",
99 model: "opus",
100 });
101 const path = promptLinkAppPath(link);
102 expect(path).toBe(new URL(link).pathname + new URL(link).search);
103 const parsed = parseNewThreadLink(new URL(link).search);
104 expect(parsed.ok && parsed.value.kind === "new" && parsed.value.prompt.text).toBe(
105 "Swear count & rating?",
106 );
107 });
108
109 it("maps app scheme and hosted links to in-app paths, and nothing else", () => {
110 expect(promptLinkAppPath("t3code://new?prompt=hi")).toBe("/new?prompt=hi");
111 expect(promptLinkAppPath("t3code-dev://threads/env-1/thread-1")).toBe("/env-1/thread-1");
112 expect(promptLinkAppPath("https://app.t3.codes/env-1/thread-1?prompt=go")).toBe(
113 "/env-1/thread-1?prompt=go",
114 );
115 expect(promptLinkAppPath("https://app.t3.codes/settings/general")).toBeNull();
116 expect(promptLinkAppPath("https://evil.test/new?prompt=hi")).toBeNull();
117 expect(promptLinkAppPath("t3code://app/index.html")).toBeNull();
118 });
119
120 it("hands an in-app path to the installed app", () => {
121 expect(promptLinkDeepLink("/new?prompt=hi")).toBe("t3code://new?prompt=hi");
122 expect(promptLinkDeepLink("/env-1/thread-1?prompt=hi")).toBe(
123 "t3code://threads/env-1/thread-1?prompt=hi",
124 );
125 });
126});
packages/shared/src/promptLinks.ts created+249
......@@ -0,0 +1,249 @@
1import { DEFAULT_HOSTED_APP_URL } from "./connectAuth.ts";
2
3/**
4 * Prompt links open T3 Code from outside it with a prompt ready to send. They never send it:
5 * a link from a stranger fills a draft, and the user reads it and presses Send. A link also
6 * cannot pick a runtime mode, branch, or worktree. Those stay the user's own defaults.
7 *
8 * Shared links are https on the hosted app, because that is what Twitter and GitHub make
9 * clickable. The `t3code://` scheme carries the same paths into desktop and mobile:
10 *
11 * https://app.t3.codes/new?prompt=…&repo=owner/name&provider=codex&model=gpt-5
12 * https://app.t3.codes/new?pr=owner/name/123&prompt=…
13 * https://app.t3.codes/<environmentId>/<threadId>?prompt=…
14 * t3code://new?… t3code://threads/<environmentId>/<threadId>?…
15 */
16
17/** URLs stay well under the limits browsers and link shorteners apply. */
18export const PROMPT_LINK_MAX_PROMPT_LENGTH = 8_000;
19
20export interface PromptLinkRepository {
21 readonly owner: string;
22 readonly name: string;
23}
24
25export interface PromptLinkPullRequest extends PromptLinkRepository {
26 readonly number: number;
27}
28
29/** A prompt as it arrived, after hidden characters were removed. */
30export interface LinkedPrompt {
31 readonly text: string;
32 /** How many invisible characters were removed. Any count above zero is a warning sign. */
33 readonly hiddenCharacters: number;
34}
35
36export type NewThreadLink =
37 | {
38 readonly kind: "new";
39 readonly prompt: LinkedPrompt;
40 readonly repository: PromptLinkRepository | null;
41 /** A provider driver kind such as `codex`. Users map it to one of their own instances. */
42 readonly provider: string | null;
43 readonly model: string | null;
44 }
45 | {
46 readonly kind: "pull-request";
47 readonly pullRequest: PromptLinkPullRequest;
48 readonly prompt: LinkedPrompt | null;
49 };
50
51export type PromptLinkResult<T> =
52 | { readonly ok: true; readonly value: T }
53 | { readonly ok: false; readonly error: string };
54
55// Zero-width and bidi controls, tag and variation-selector blocks, and other characters that
56// render as nothing. A model reads them; the person checking the draft cannot see them, which
57// makes them the usual way to hide instructions in a prompt. U+FE0E/U+FE0F stay: emoji use them.
58// Line and paragraph separators become plain newlines, so the draft shows the break the model reads.
59const HIDDEN_CHARACTERS =
60 // oxlint-disable-next-line no-control-regex, no-misleading-character-class -- these are the characters it removes.
61 /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B\u200C\u200E\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0D\uFEFF\uFFA0\uFFF0-\uFFFB\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E0FFF}]/gu;
62// Emoji and the pieces emoji sequences are built from: pictographs, symbols, skin tones, and the
63// emoji presentation selector. Numeric ranges, because Hermes lacks Unicode property escapes.
64const isEmojiPart = (codePoint: number | undefined) =>
65 codePoint !== undefined &&
66 ((codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
67 (codePoint >= 0x2190 && codePoint <= 0x2bff) ||
68 codePoint === 0xfe0f);
69
70/** Removes characters a reader cannot see, and counts them. */
71export function sanitizeLinkedPrompt(raw: string): LinkedPrompt {
72 let hiddenCharacters = 0;
73 const visible = Array.from(
74 raw.replace(/\r\n?|[\u2028\u2029]/g, "\n").replace(HIDDEN_CHARACTERS, () => {
75 hiddenCharacters++;
76 return "";
77 }),
78 );
79 // A zero-width joiner is visible work only inside an emoji sequence, such as a family.
80 const text = visible
81 .filter((character, index) => {
82 if (character !== "\u200D") return true;
83 const kept =
84 isEmojiPart(visible[index - 1]?.codePointAt(0)) &&
85 isEmojiPart(visible[index + 1]?.codePointAt(0));
86 if (!kept) hiddenCharacters++;
87 return kept;
88 })
89 .join("");
90 return { text: text.trim(), hiddenCharacters };
91}
92
93const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
94const NAME = /^[A-Za-z0-9._-]{1,100}$/;
95
96function repositoryOf(owner: string, name: string): PromptLinkRepository | null {
97 const trimmedName = name.replace(/\.git$/i, "");
98 if (!OWNER.test(owner) || !NAME.test(trimmedName) || /^\.+$/.test(trimmedName)) return null;
99 return { owner, name: trimmedName };
100}
101
102/** `owner/name`, or a GitHub repository URL. */
103export function parsePromptLinkRepository(value: string): PromptLinkRepository | null {
104 const trimmed = value.trim();
105 const url = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/?$/i.exec(trimmed);
106 const short = /^([^/\s]+)\/([^/\s]+)$/.exec(trimmed);
107 const match = url ?? short;
108 return match?.[1] && match[2] ? repositoryOf(match[1], match[2]) : null;
109}
110
111/** `owner/name/123`, `owner/name#123`, or a GitHub pull request URL. */
112function parsePromptLinkPullRequest(value: string): PromptLinkPullRequest | null {
113 const trimmed = value.trim();
114 const match =
115 /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec(
116 trimmed,
117 ) ?? /^([^/\s#]+)\/([^/\s#]+)(?:\/|#)(\d+)$/.exec(trimmed);
118 if (!match?.[1] || !match[2] || !match[3]) return null;
119 const repository = repositoryOf(match[1], match[2]);
120 const number = Number(match[3]);
121 return repository && Number.isSafeInteger(number) && number > 0
122 ? { ...repository, number }
123 : null;
124}
125
126/** The pull request's page on GitHub, the reference `preparePullRequestThread` takes. */
127export function promptLinkPullRequestUrl(pullRequest: PromptLinkPullRequest): string {
128 return `https://github.com/${pullRequest.owner}/${pullRequest.name}/pull/${pullRequest.number}`;
129}
130
131function readPrompt(params: URLSearchParams): PromptLinkResult<LinkedPrompt | null> {
132 const raw = params.get("prompt");
133 if (raw === null) return { ok: true, value: null };
134 if (raw.length > PROMPT_LINK_MAX_PROMPT_LENGTH) {
135 return {
136 ok: false,
137 error: `This prompt is longer than ${PROMPT_LINK_MAX_PROMPT_LENGTH.toLocaleString("en-US")} characters, so T3 Code did not open it.`,
138 };
139 }
140 const prompt = sanitizeLinkedPrompt(raw);
141 if (prompt.text.length === 0 && prompt.hiddenCharacters > 0) {
142 return {
143 ok: false,
144 error:
145 "This prompt had only hidden characters, so it may be malicious. T3 Code did not open it.",
146 };
147 }
148 return { ok: true, value: prompt.text.length > 0 ? prompt : null };
149}
150
151const optionalParam = (params: URLSearchParams, key: string) => params.get(key)?.trim() || null;
152
153/** Reads the query of a `/new` link. */
154export function parseNewThreadLink(search: string): PromptLinkResult<NewThreadLink> {
155 const params = new URLSearchParams(search);
156 const prompt = readPrompt(params);
157 if (!prompt.ok) return prompt;
158 const pr = params.get("pr");
159 if (pr !== null) {
160 const pullRequest = parsePromptLinkPullRequest(pr);
161 if (pullRequest === null) {
162 return { ok: false, error: "This link names a pull request T3 Code cannot read." };
163 }
164 return { ok: true, value: { kind: "pull-request", pullRequest, prompt: prompt.value } };
165 }
166 if (prompt.value === null) return { ok: false, error: "This link has no prompt." };
167 const repo = params.get("repo");
168 const repository = repo === null ? null : parsePromptLinkRepository(repo);
169 if (repo !== null && repository === null) {
170 return { ok: false, error: "This link names a repository T3 Code cannot read." };
171 }
172 return {
173 ok: true,
174 value: {
175 kind: "new",
176 prompt: prompt.value,
177 repository,
178 provider: optionalParam(params, "provider")?.toLowerCase() ?? null,
179 model: optionalParam(params, "model"),
180 },
181 };
182}
183
184/** Reads the `prompt` of a thread link. Null when the link carries none. */
185export function parseThreadLinkPrompt(search: string): PromptLinkResult<LinkedPrompt | null> {
186 return readPrompt(new URLSearchParams(search));
187}
188
189/** Drops the link's own parameters from a query, keeping anything else the route uses. */
190export function withoutPromptLinkParams(search: string): string {
191 const params = new URLSearchParams(search);
192 for (const key of ["prompt", "repo", "pr", "provider", "model"]) params.delete(key);
193 const rest = params.toString();
194 return rest ? `?${rest}` : "";
195}
196
197/** A shareable `/new` link on the hosted app. */
198export function buildNewThreadLink(input: {
199 readonly prompt: string;
200 readonly repository?: PromptLinkRepository | null;
201 readonly provider?: string | null;
202 readonly model?: string | null;
203 readonly origin?: string;
204}): string {
205 const url = new URL("/new", input.origin ?? DEFAULT_HOSTED_APP_URL);
206 url.searchParams.set("prompt", input.prompt);
207 if (input.repository) {
208 url.searchParams.set("repo", `${input.repository.owner}/${input.repository.name}`);
209 }
210 if (input.provider) url.searchParams.set("provider", input.provider);
211 if (input.provider && input.model) url.searchParams.set("model", input.model);
212 return url.toString();
213}
214
215const APP_SCHEME = /^t3code(?:-[a-z]+)?:$/;
216const THREAD_PATH = /^\/([^/]+)\/([^/]+)\/?$/;
217
218/**
219 * The in-app path a prompt link or thread deep link opens, or null for any other URL.
220 * Desktop uses it for links the OS hands over, both `t3code://` ones and, as the default
221 * browser on macOS, hosted-app ones.
222 */
223export function promptLinkAppPath(
224 value: string,
225 hostedAppUrl: string = DEFAULT_HOSTED_APP_URL,
226): string | null {
227 if (!URL.canParse(value)) return null;
228 const url = new URL(value);
229 if (APP_SCHEME.test(url.protocol)) {
230 if (url.host === "new" && (url.pathname === "" || url.pathname === "/")) {
231 return `/new${url.search}`;
232 }
233 const thread = url.host === "threads" ? THREAD_PATH.exec(url.pathname) : null;
234 return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null;
235 }
236 if (!URL.canParse(hostedAppUrl) || url.origin !== new URL(hostedAppUrl).origin) return null;
237 if (url.pathname === "/new") return `/new${url.search}`;
238 // Hosted thread links only open in the app when they carry a prompt. A plain one is just a
239 // page on the hosted app.
240 const thread = url.searchParams.has("prompt") ? THREAD_PATH.exec(url.pathname) : null;
241 return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null;
242}
243
244/** The `t3code://` link that hands an in-app path to an installed desktop or mobile app. */
245export function promptLinkDeepLink(appPath: string, scheme = "t3code"): string {
246 const url = new URL(appPath, "https://app.invalid");
247 const host = url.pathname === "/new" ? "new" : `threads${url.pathname}`;
248 return `${scheme}://${host}${url.search}`;
249}