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)(...@@ -396,7 +396,7 @@ it.effect.each(["startup", "open-url"] as const)(
396 }).pipe(Effect.scoped),396 }).pipe(Effect.scoped),
397);397);
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", () =>
400 Effect.gen(function* () {400 Effect.gen(function* () {
401 storageMock.mockReturnValue(storageAdapter);401 storageMock.mockReturnValue(storageAdapter);
402 createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true });402 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", () =>...@@ -420,10 +420,19 @@ it.effect("hands a web link to the renderer and leaves other links alone", () =>
420 // macOS hands the default browser every web link.420 // macOS hands the default browser every web link.
421 assert.strictEqual(open("https://example.com/page"), 1);421 assert.strictEqual(open("https://example.com/page"), 1);
422 assert.strictEqual(open("http://localhost:3000/"), 1);422 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);
423 // Anything else is not a web page; Electron keeps its own handling.426 // Anything else is not a web page; Electron keeps its own handling.
424 assert.strictEqual(open("mailto:hello@example.com"), 0);427 assert.strictEqual(open("mailto:hello@example.com"), 0);
428 assert.strictEqual(open("t3code://somewhere-else"), 0);
425 yield* Effect.yieldNow;429 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 ]);
427 }).pipe(436 }).pipe(
428 Effect.provide(layerDesktopClerk(true, [], "darwin")),437 Effect.provide(layerDesktopClerk(true, [], "darwin")),
429 Effect.provideService(HostProcess.Arguments, ["t3"]),438 Effect.provideService(HostProcess.Arguments, ["t3"]),
apps/desktop/src/app/DesktopClerk.ts+21-3
...@@ -12,6 +12,7 @@ import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/code...@@ -12,6 +12,7 @@ import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/code
12import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts";12import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts";
13import * as ElectronShell from "../electron/ElectronShell.ts";13import * as ElectronShell from "../electron/ElectronShell.ts";
14import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";14import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl";
15import { promptLinkAppPath } from "@t3tools/shared/promptLinks";
15import * as HostProcess from "@t3tools/shared/HostProcess";16import * as HostProcess from "@t3tools/shared/HostProcess";
16import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";17import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth";
17import * as ElectronApp from "../electron/ElectronApp.ts";18import * as ElectronApp from "../electron/ElectronApp.ts";
...@@ -188,8 +189,15 @@ export const make = Effect.gen(function* () {...@@ -188,8 +189,15 @@ export const make = Effect.gen(function* () {
188 );189 );
189 return true;190 return true;
190 };191 };
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 };
191 const args = yield* HostProcess.Arguments;199 const args = yield* HostProcess.Arguments;
192 args.some((value) => startProviderAuthHandoff(value));200 args.some((value) => startProviderAuthHandoff(value) || openAppLink(value));
193 // As the default browser, macOS hands T3 Code every web link through the same event.201 // As the default browser, macOS hands T3 Code every web link through the same event.
194 const openWebLink = (url: string) => {202 const openWebLink = (url: string) => {
195 if (!DesktopWebLinks.isWebLink(url)) return false;203 if (!DesktopWebLinks.isWebLink(url)) return false;
...@@ -197,7 +205,12 @@ export const make = Effect.gen(function* () {...@@ -197,7 +205,12 @@ export const make = Effect.gen(function* () {
197 return true;205 return true;
198 };206 };
199 yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => {207 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 )
201 event.preventDefault();214 event.preventDefault();
202 });215 });
203 // A browser opens HTML files too, which macOS hands over by path.216 // A browser opens HTML files too, which macOS hands over by path.
...@@ -207,7 +220,12 @@ export const make = Effect.gen(function* () {...@@ -207,7 +220,12 @@ export const make = Effect.gen(function* () {
207 void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href));220 void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href));
208 });221 });
209 yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => {222 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 )
211 return;229 return;
212 void runPromise(230 void runPromise(
213 Effect.gen(function* () {231 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);...@@ -18,7 +18,8 @@ export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path);
1818
19/**19/**
20 * Web links and HTML files macOS opens with T3 Code once it is the default20 * Web links and HTML files macOS opens with T3 Code once it is the default
21 * browser. Files arrive as file:// URLs. They can21 * browser, plus `t3code://` prompt and thread links on every platform. Files
22 * arrive as file:// URLs. They can
22 * arrive before the app is ready (the link that launched it) or while the web23 * arrive before the app is ready (the link that launched it) or while the web
23 * app reloads, so they wait until the renderer says it is listening.24 * app reloads, so they wait until the renderer says it is listening.
24 */25 */
apps/mobile/src/components/CopyTextButton.tsx+3
...@@ -10,6 +10,8 @@ export const CopyTextButton = memo(function CopyTextButton(props: {...@@ -10,6 +10,8 @@ export const CopyTextButton = memo(function CopyTextButton(props: {
10 readonly accessibilityLabel: string;10 readonly accessibilityLabel: string;
11 readonly text: string;11 readonly text: string;
12 readonly onCopy?: () => Promise<void>;12 readonly onCopy?: () => Promise<void>;
13 /** Lets a long-press menu wrap the button (`ControlPillMenu` injects it on Android). */
14 readonly onLongPress?: () => void;
13 readonly tintColor?: ColorValue;15 readonly tintColor?: ColorValue;
14 readonly copiedTintColor?: ColorValue;16 readonly copiedTintColor?: ColorValue;
15 readonly backgroundColor?: ColorValue;17 readonly backgroundColor?: ColorValue;
...@@ -35,6 +37,7 @@ export const CopyTextButton = memo(function CopyTextButton(props: {...@@ -35,6 +37,7 @@ export const CopyTextButton = memo(function CopyTextButton(props: {
35 accessibilityLabel={copied ? "Copied" : props.accessibilityLabel}37 accessibilityLabel={copied ? "Copied" : props.accessibilityLabel}
36 disabled={props.text.length === 0}38 disabled={props.text.length === 0}
37 hitSlop={8}39 hitSlop={8}
40 onLongPress={props.onLongPress}
38 onPress={async () => {41 onPress={async () => {
39 try {42 try {
40 if (props.onCopy) await props.onCopy();43 if (props.onCopy) await props.onCopy();
apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx+2
...@@ -7,6 +7,8 @@ type AddProjectDestinationRouteParams = {...@@ -7,6 +7,8 @@ type AddProjectDestinationRouteParams = {
7 readonly remoteUrl?: string | string[];7 readonly remoteUrl?: string | string[];
8 readonly repositoryTitle?: string | string[];8 readonly repositoryTitle?: string | string[];
9 readonly repositoryName?: string | string[];9 readonly repositoryName?: string | string[];
10 /** A prompt link's prompt, for the draft the clone opens. */
11 readonly prompt?: string | string[];
10};12};
1113
12export function AddProjectDestinationRoute({14export function AddProjectDestinationRoute({
apps/mobile/src/features/projects/AddProjectScreen.tsx+34-1
...@@ -85,6 +85,11 @@ import {...@@ -85,6 +85,11 @@ import {
85 useSavedRemoteConnections,85 useSavedRemoteConnections,
86} from "../../state/use-remote-environment-registry";86} from "../../state/use-remote-environment-registry";
87import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic";87import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic";
88import {
89 createNewTaskDraft,
90 setComposerDraftText,
91 waitForComposerDraftsLoaded,
92} from "../../state/use-composer-drafts";
8893
89interface EnvironmentOption {94interface EnvironmentOption {
90 readonly environmentId: EnvironmentId;95 readonly environmentId: EnvironmentId;
...@@ -676,13 +681,31 @@ export function AddProjectSourceScreen() {...@@ -676,13 +681,31 @@ export function AddProjectSourceScreen() {
676681
677function openNewTaskDraft(682function openNewTaskDraft(
678 navigation: { dispatch: (action: ReturnType<typeof CommonActions.reset>) => void },683 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 },
680) {691) {
681 navigation.dispatch(692 navigation.dispatch(
682 CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }),693 CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }),
683 );694 );
684}695}
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
686function useCreateProject(environment: EnvironmentOption | null) {709function useCreateProject(environment: EnvironmentOption | null) {
687 const navigation = useNavigation();710 const navigation = useNavigation();
688 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });711 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
...@@ -1302,6 +1325,7 @@ export function AddProjectDestinationScreen(props: {...@@ -1302,6 +1325,7 @@ export function AddProjectDestinationScreen(props: {
1302 readonly remoteUrl?: string | string[];1325 readonly remoteUrl?: string | string[];
1303 readonly repositoryTitle?: string | string[];1326 readonly repositoryTitle?: string | string[];
1304 readonly repositoryName?: string | string[];1327 readonly repositoryName?: string | string[];
1328 readonly prompt?: string | string[];
1305}) {1329}) {
1306 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {1330 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
1307 reportFailure: false,1331 reportFailure: false,
...@@ -1323,6 +1347,7 @@ export function AddProjectDestinationScreen(props: {...@@ -1323,6 +1347,7 @@ export function AddProjectDestinationScreen(props: {
1323 const createProject = useCreateProject(environment);1347 const createProject = useCreateProject(environment);
1324 const remoteUrl = stringParam(props.remoteUrl);1348 const remoteUrl = stringParam(props.remoteUrl);
1325 const repositoryTitle = stringParam(props.repositoryTitle);1349 const repositoryTitle = stringParam(props.repositoryTitle);
1350 const prompt = stringParam(props.prompt);
1326 // A lookup derives this from "owner/repo", a pasted clone URL from its own1351 // A lookup derives this from "owner/repo", a pasted clone URL from its own
1327 // last segment. Older links without the param keep the browsed folder.1352 // last segment. Older links without the param keep the browsed folder.
1328 // Trim once here: the path input and the folder-list filter must compare the1353 // Trim once here: the path input and the folder-list filter must compare the
...@@ -1394,6 +1419,13 @@ export function AddProjectDestinationScreen(props: {...@@ -1394,6 +1419,13 @@ export function AddProjectDestinationScreen(props: {
1394 projectId,1419 projectId,
1395 title,1420 title,
1396 cloning: "1",1421 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 : {}),
1397 });1429 });
1398 }1430 }
1399 }1431 }
...@@ -1424,6 +1456,7 @@ export function AddProjectDestinationScreen(props: {...@@ -1424,6 +1456,7 @@ export function AddProjectDestinationScreen(props: {
1424 isSubmitting,1456 isSubmitting,
1425 navigation,1457 navigation,
1426 pathInput,1458 pathInput,
1459 prompt,
1427 remoteUrl,1460 remoteUrl,
1428 startProjectClone,1461 startProjectClone,
1429 ]);1462 ]);
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...@@ -31,12 +31,19 @@ import { useRemoteConnectionStatus } from "../../state/use-remote-environment-re
31import type { WorkspaceState } from "../../state/workspaceModel";31import type { WorkspaceState } from "../../state/workspaceModel";
32import { useWorkspaceState } from "../../state/workspace";32import { useWorkspaceState } from "../../state/workspace";
33import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";33import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
34import { usePromptLinkResolver } from "../prompt-links/usePromptLinkResolver";
34import { useIncomingShare } from "../sharing/IncomingShareProvider";35import { useIncomingShare } from "../sharing/IncomingShareProvider";
35import { useNewTaskFlow } from "./new-task-flow-provider";36import { useNewTaskFlow } from "./new-task-flow-provider";
36import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection";37import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection";
3738
38type NewTaskRouteParams = {39type NewTaskRouteParams = {
39 readonly incomingShareId?: string | string[];40 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[];
40};47};
4148
42function deriveProjectEmptyState(catalogState: WorkspaceState): {49function deriveProjectEmptyState(catalogState: WorkspaceState): {
...@@ -144,6 +151,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara...@@ -144,6 +151,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
144 const isFocused = useIsFocused();151 const isFocused = useIsFocused();
145 const insets = useSafeAreaInsets();152 const insets = useSafeAreaInsets();
146 const { getShare, releaseShareReservation } = useIncomingShare();153 const { getShare, releaseShareReservation } = useIncomingShare();
154 usePromptLinkResolver(route.params);
147 const routeShareId = Array.isArray(route.params?.incomingShareId)155 const routeShareId = Array.isArray(route.params?.incomingShareId)
148 ? route.params.incomingShareId[0]156 ? route.params.incomingShareId[0]
149 : route.params?.incomingShareId;157 : route.params?.incomingShareId;
apps/mobile/src/features/threads/ThreadFeed.tsx+43-4
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";31import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
32import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";32import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
33import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";33import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
34import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks";
34import {35import {
35 parseComposerContextHref,36 parseComposerContextHref,
36 collectComposerContextReferences,37 collectComposerContextReferences,
...@@ -133,6 +134,8 @@ import {...@@ -133,6 +134,8 @@ import {
133 type MediaVideoPreviewSource,134 type MediaVideoPreviewSource,
134} from "../../lib/videoPreviewSource";135} from "../../lib/videoPreviewSource";
135import { CopyTextButton } from "../../components/CopyTextButton";136import { CopyTextButton } from "../../components/CopyTextButton";
137import { ControlPillMenu } from "../../components/ControlPill";
138import { copyPromptLink } from "../prompt-links/prompt-link-actions";
136import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection";139import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection";
137import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";140import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";
138import {141import {
...@@ -319,6 +322,45 @@ async function waitForThreadShell(...@@ -319,6 +322,45 @@ async function waitForThreadShell(
319 });322 });
320}323}
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
322function AssistantForkButton(props: {364function AssistantForkButton(props: {
323 readonly environmentId: EnvironmentId;365 readonly environmentId: EnvironmentId;
324 readonly iconColor: ColorValue;366 readonly iconColor: ColorValue;
...@@ -1310,8 +1352,7 @@ function renderFeedEntry(...@@ -1310,8 +1352,7 @@ function renderFeedEntry(
1310 </Pressable>1352 </Pressable>
1311 ) : null}1353 ) : null}
1312 {presentation.text.trim().length > 0 ? (1354 {presentation.text.trim().length > 0 ? (
1313 <CopyTextButton1355 <UserMessageCopyButton
1314 accessibilityLabel="Copy message"
1315 text={presentation.text}1356 text={presentation.text}
1316 onCopy={1357 onCopy={
1317 message.context1358 message.context
...@@ -1324,8 +1365,6 @@ function renderFeedEntry(...@@ -1324,8 +1365,6 @@ function renderFeedEntry(
1324 : undefined1365 : undefined
1325 }1366 }
1326 tintColor={iconSubtleColor}1367 tintColor={iconSubtleColor}
1327 buttonSize={28}
1328 iconSize={13}
1329 />1368 />
1330 ) : null}1369 ) : null}
1331 </View>1370 </View>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+4
...@@ -86,6 +86,7 @@ import {...@@ -86,6 +86,7 @@ import {
86 type ThreadInspectorMode,86 type ThreadInspectorMode,
87} from "./thread-inspector-content-stack";87} from "./thread-inspector-content-stack";
88import { threadRouteIsHydrating } from "./thread-route-hydration";88import { threadRouteIsHydrating } from "./thread-route-hydration";
89import { useThreadPromptLink } from "../prompt-links/useThreadPromptLink";
89import { ThreadHeader } from "./ThreadHeader";90import { ThreadHeader } from "./ThreadHeader";
90import {91import {
91 useAndroidWorkspaceBack,92 useAndroidWorkspaceBack,
...@@ -151,6 +152,8 @@ function OpeningThreadLoadingScreen() {...@@ -151,6 +152,8 @@ function OpeningThreadLoadingScreen() {
151type ThreadRouteScreenRouteProps = StaticScreenProps<{152type ThreadRouteScreenRouteProps = StaticScreenProps<{
152 readonly environmentId: string;153 readonly environmentId: string;
153 readonly threadId: string;154 readonly threadId: string;
155 /** A prompt link's draft text. See useThreadPromptLink. */
156 readonly prompt?: string | string[];
154}>;157}>;
155158
156interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {159interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {
...@@ -210,6 +213,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {...@@ -210,6 +213,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
210 ? null213 ? null
211 : scopedThreadKey(selectedThread.environmentId, selectedThread.id);214 : scopedThreadKey(selectedThread.environmentId, selectedThread.id);
212 const selectedThreadDetailState = useSelectedThreadDetailState();215 const selectedThreadDetailState = useSelectedThreadDetailState();
216 useThreadPromptLink(params, routeThreadKey);
213217
214 if (environmentId === null || threadIdRaw === null) {218 if (environmentId === null || threadIdRaw === null) {
215 return <OpeningThreadLoadingScreen />;219 return <OpeningThreadLoadingScreen />;
apps/web/src/components/CommandPalette.tsx+59
...@@ -99,6 +99,11 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra...@@ -99,6 +99,11 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra
99import { useHandleNewThread } from "../hooks/useHandleNewThread";99import { useHandleNewThread } from "../hooks/useHandleNewThread";
100import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";100import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
101import { writeTextToClipboard } from "../hooks/useCopyToClipboard";101import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
102import {
103 composerPromptLink,
104 copyPromptLink,
105 promptLinkRepositoryOf,
106} from "./promptLinks/copyPromptLink";
102import { useClientSettings } from "../hooks/useSettings";107import { useClientSettings } from "../hooks/useSettings";
103import { useTheme } from "../hooks/useTheme";108import { useTheme } from "../hooks/useTheme";
104import { useCustomThemes } from "../hooks/useCustomThemes";109import { useCustomThemes } from "../hooks/useCustomThemes";
...@@ -641,6 +646,10 @@ function OpenCommandPaletteDialog(props: {...@@ -641,6 +646,10 @@ function OpenCommandPaletteDialog(props: {
641 const availableSettingsSearchItems = useAvailableSettingsSearchItems();646 const availableSettingsSearchItems = useAvailableSettingsSearchItems();
642 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =647 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
643 useHandleNewThread();648 useHandleNewThread();
649 const paletteRouteTarget = useParams({
650 strict: false,
651 select: (params) => resolveThreadRouteTarget(params),
652 });
644 const projects = useProjects();653 const projects = useProjects();
645 const referenceThreadRef =654 const referenceThreadRef =
646 pathname === "/pull-requests"655 pathname === "/pull-requests"
...@@ -1861,6 +1870,56 @@ function OpenCommandPaletteDialog(props: {...@@ -1861,6 +1870,56 @@ function OpenCommandPaletteDialog(props: {
1861 });1870 });
1862 }1871 }
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
1864 if (activeThreadReferenceCopyTarget !== null) {1923 if (activeThreadReferenceCopyTarget !== null) {
1865 actionItems.push({1924 actionItems.push({
1866 kind: "action",1925 kind: "action",
apps/web/src/components/chat/MessageCopyButton.tsx+8-3
...@@ -13,12 +13,17 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -13,12 +13,17 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1313
14export const MessageCopyButton = memo(function MessageCopyButton({14export const MessageCopyButton = memo(function MessageCopyButton({
15 text,15 text,
16 label = "Copy message",
17 icon = Copy,
16 extraFlavors,18 extraFlavors,
17 size = "xs",19 size = "xs",
18 variant = "outline",20 variant = "outline",
19 className,21 className,
20}: {22}: {
21 text: string;23 text: string;
24 label?: string;
25 /** The icon shown until the copy lands. */
26 icon?: typeof Copy;
22 /** Additional clipboard types written beside `text/plain` when the platform allows it. */27 /** Additional clipboard types written beside `text/plain` when the platform allows it. */
23 extraFlavors?: Readonly<Record<string, string>>;28 extraFlavors?: Readonly<Record<string, string>>;
24 size?: "xs" | "icon-xs";29 size?: "xs" | "icon-xs";
...@@ -38,7 +43,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({...@@ -38,7 +43,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({
38 <TooltipTrigger43 <TooltipTrigger
39 render={44 render={
40 <Button45 <Button
41 aria-label="Copy message"46 aria-label={label}
42 disabled={isCopied}47 disabled={isCopied}
43 onClick={() => copyToClipboard(text)}48 onClick={() => copyToClipboard(text)}
44 ref={ref}49 ref={ref}
...@@ -51,11 +56,11 @@ export const MessageCopyButton = memo(function MessageCopyButton({...@@ -51,11 +56,11 @@ export const MessageCopyButton = memo(function MessageCopyButton({
51 >56 >
52 <MorphIcon57 <MorphIcon
53 className={cn("size-3", isCopied && "text-primary")}58 className={cn("size-3", isCopied && "text-primary")}
54 icon={isCopied ? Check : Copy}59 icon={isCopied ? Check : icon}
55 />60 />
56 </TooltipTrigger>61 </TooltipTrigger>
57 <TooltipPopup>62 <TooltipPopup>
58 <p>Copy message</p>63 <p>{label}</p>
59 </TooltipPopup>64 </TooltipPopup>
60 </Tooltip>65 </Tooltip>
61 );66 );
apps/web/src/components/chat/MessagesTimeline.tsx+17-9
...@@ -152,7 +152,7 @@ import {...@@ -152,7 +152,7 @@ import {
152 ZapIcon,152 ZapIcon,
153 RotateCcwIcon,153 RotateCcwIcon,
154} from "lucide-react";154} from "lucide-react";
155import { ChevronDown, ChevronRight } from "lucide";155import { ChevronDown, ChevronRight, Link as LinkIconNode } from "lucide";
156import type {156import type {
157 ComposerContextId,157 ComposerContextId,
158 ComposerContextRecord,158 ComposerContextRecord,
...@@ -186,6 +186,7 @@ import {...@@ -186,6 +186,7 @@ import {
186 timelineContentOverflowsViewport,186 timelineContentOverflowsViewport,
187} from "./timelineScrollAnchoring";187} from "./timelineScrollAnchoring";
188import { MessageCopyButton } from "./MessageCopyButton";188import { MessageCopyButton } from "./MessageCopyButton";
189import { promptLinkFor } from "../promptLinks/copyPromptLink";
189import { PierreEntryIcon } from "./PierreEntryIcon";190import { PierreEntryIcon } from "./PierreEntryIcon";
190import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon";191import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon";
191import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";192import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
...@@ -2326,6 +2327,12 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -2326,6 +2327,12 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
2326 .map((record) => record.contextId),2327 .map((record) => record.contextId),
2327 [resolvedContext.records],2328 [resolvedContext.records],
2328 );2329 );
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]);
2329 const contextClipboardFragment =2336 const contextClipboardFragment =
2330 resolvedContext.records.length === 02337 resolvedContext.records.length === 0
2331 ? null2338 ? null
...@@ -2615,14 +2622,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -2615,14 +2622,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
2615 {resolvedContext.text && (2622 {resolvedContext.text && (
2616 <MessageCopyButton2623 <MessageCopyButton
2617 // Structured paste needs the canonical links to retain their positions.2624 // Structured paste needs the canonical links to retain their positions.
2618 text={2625 text={contextClipboardFragment ? resolvedContext.text : plainText}
2619 contextClipboardFragment
2620 ? resolvedContext.text
2621 : replaceComposerContextReferences(
2622 resolvedContext.text,
2623 (reference) => reference.label,
2624 )
2625 }
2626 {...(contextClipboardFragment2626 {...(contextClipboardFragment
2627 ? {2627 ? {
2628 extraFlavors: { [COMPOSER_CONTEXT_CLIPBOARD_MIME]: contextClipboardFragment },2628 extraFlavors: { [COMPOSER_CONTEXT_CLIPBOARD_MIME]: contextClipboardFragment },
...@@ -2631,6 +2631,14 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -2631,6 +2631,14 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
2631 variant="ghost"2631 variant="ghost"
2632 />2632 />
2633 )}2633 )}
2634 {promptLink !== null && (
2635 <MessageCopyButton
2636 text={promptLink}
2637 label="Copy prompt link"
2638 icon={LinkIconNode}
2639 variant="ghost"
2640 />
2641 )}
2634 </div>2642 </div>
2635 </div>2643 </div>
2636 </div>2644 </div>
apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx+11-3
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";3import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
4import { promptLinkAppPath } from "@t3tools/shared/promptLinks";
4import { useRouter } from "@tanstack/react-router";5import { useRouter } from "@tanstack/react-router";
5import { useEffect, useEffectEvent, useRef } from "react";6import { useEffect, useEffectEvent, useRef } from "react";
67
...@@ -45,9 +46,11 @@ function threadShowingBrowser(...@@ -45,9 +46,11 @@ function threadShowingBrowser(
45}46}
4647
47/**48/**
48 * Opens each web link or HTML file the OS hands T3 Code as the default browser49 * Opens each link the OS hands T3 Code. Prompt and thread links, by `t3code://`
49 * (macOS). With a browser panel on screen the page opens as a new tab there;50 * or as hosted-app pages, open in the app. Other web links and HTML files arrive
50 * otherwise it starts a thread without a project, its browser panel maximized.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.
51 */54 */
52export function DesktopWebLinkCoordinator() {55export function DesktopWebLinkCoordinator() {
53 const router = useRouter();56 const router = useRouter();
...@@ -93,6 +96,11 @@ export function DesktopWebLinkCoordinator() {...@@ -93,6 +96,11 @@ export function DesktopWebLinkCoordinator() {
93 };96 };
9497
95 const openLink = useEffectEvent(async (url: string) => {98 const openLink = useEffectEvent(async (url: string) => {
99 const appPath = promptLinkAppPath(url);
100 if (appPath !== null) {
101 await router.navigate({ href: appPath });
102 return;
103 }
96 const isFile = url.startsWith("file:");104 const isFile = url.startsWith("file:");
97 const routeParams = router.state.matches.at(-1)?.params ?? {};105 const routeParams = router.state.matches.at(-1)?.params ?? {};
98 const threadRef =106 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 {...@@ -10,7 +10,8 @@ export interface HostedPairingRequest {
1010
11export type HostedAppChannel = "latest" | "nightly";11export type HostedAppChannel = "latest" | "nightly";
1212
13function configuredHostedAppUrl(): string {13/** The hosted app origin shared links point at. */
14export function configuredHostedAppUrl(): string {
14 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;15 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
15}16}
1617
apps/web/src/routeTree.gen.ts+21
...@@ -33,6 +33,7 @@ import { Route as SettingsArchivedRouteImport } from './routes/settings.archived...@@ -33,6 +33,7 @@ import { Route as SettingsArchivedRouteImport } from './routes/settings.archived
33import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'33import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance'
34import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey'34import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey'
35import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests'35import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests'
36import { Route as ChatNewRouteImport } from './routes/_chat.new'
36import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'37import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
37import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'38import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
3839
...@@ -156,6 +157,11 @@ const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({...@@ -156,6 +157,11 @@ const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({
156 path: '/pull-requests',157 path: '/pull-requests',
157 getParentRoute: () => ChatRoute,158 getParentRoute: () => ChatRoute,
158} as any)159} as any)
160const ChatNewRoute = ChatNewRouteImport.update({
161 id: '/new',
162 path: '/new',
163 getParentRoute: () => ChatRoute,
164} as any)
159const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({165const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
160 id: '/draft/$draftId',166 id: '/draft/$draftId',
161 path: '/draft/$draftId',167 path: '/draft/$draftId',
...@@ -176,6 +182,7 @@ export interface FileRoutesByFullPath {...@@ -176,6 +182,7 @@ export interface FileRoutesByFullPath {
176 '/settings': typeof SettingsRouteWithChildren182 '/settings': typeof SettingsRouteWithChildren
177 '/usage': typeof UsageRoute183 '/usage': typeof UsageRoute
178 '/welcome': typeof WelcomeRoute184 '/welcome': typeof WelcomeRoute
185 '/new': typeof ChatNewRoute
179 '/pull-requests': typeof ChatPullRequestsRoute186 '/pull-requests': typeof ChatPullRequestsRoute
180 '/projects/$projectKey': typeof ProjectsProjectKeyRoute187 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
181 '/settings/appearance': typeof SettingsAppearanceRoute188 '/settings/appearance': typeof SettingsAppearanceRoute
...@@ -202,6 +209,7 @@ export interface FileRoutesByTo {...@@ -202,6 +209,7 @@ export interface FileRoutesByTo {
202 '/settings': typeof SettingsRouteWithChildren209 '/settings': typeof SettingsRouteWithChildren
203 '/usage': typeof UsageRoute210 '/usage': typeof UsageRoute
204 '/welcome': typeof WelcomeRoute211 '/welcome': typeof WelcomeRoute
212 '/new': typeof ChatNewRoute
205 '/pull-requests': typeof ChatPullRequestsRoute213 '/pull-requests': typeof ChatPullRequestsRoute
206 '/projects/$projectKey': typeof ProjectsProjectKeyRoute214 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
207 '/settings/appearance': typeof SettingsAppearanceRoute215 '/settings/appearance': typeof SettingsAppearanceRoute
...@@ -231,6 +239,7 @@ export interface FileRoutesById {...@@ -231,6 +239,7 @@ export interface FileRoutesById {
231 '/settings': typeof SettingsRouteWithChildren239 '/settings': typeof SettingsRouteWithChildren
232 '/usage': typeof UsageRoute240 '/usage': typeof UsageRoute
233 '/welcome': typeof WelcomeRoute241 '/welcome': typeof WelcomeRoute
242 '/_chat/new': typeof ChatNewRoute
234 '/_chat/pull-requests': typeof ChatPullRequestsRoute243 '/_chat/pull-requests': typeof ChatPullRequestsRoute
235 '/projects/$projectKey': typeof ProjectsProjectKeyRoute244 '/projects/$projectKey': typeof ProjectsProjectKeyRoute
236 '/settings/appearance': typeof SettingsAppearanceRoute245 '/settings/appearance': typeof SettingsAppearanceRoute
...@@ -261,6 +270,7 @@ export interface FileRouteTypes {...@@ -261,6 +270,7 @@ export interface FileRouteTypes {
261 | '/settings'270 | '/settings'
262 | '/usage'271 | '/usage'
263 | '/welcome'272 | '/welcome'
273 | '/new'
264 | '/pull-requests'274 | '/pull-requests'
265 | '/projects/$projectKey'275 | '/projects/$projectKey'
266 | '/settings/appearance'276 | '/settings/appearance'
...@@ -287,6 +297,7 @@ export interface FileRouteTypes {...@@ -287,6 +297,7 @@ export interface FileRouteTypes {
287 | '/settings'297 | '/settings'
288 | '/usage'298 | '/usage'
289 | '/welcome'299 | '/welcome'
300 | '/new'
290 | '/pull-requests'301 | '/pull-requests'
291 | '/projects/$projectKey'302 | '/projects/$projectKey'
292 | '/settings/appearance'303 | '/settings/appearance'
...@@ -315,6 +326,7 @@ export interface FileRouteTypes {...@@ -315,6 +326,7 @@ export interface FileRouteTypes {
315 | '/settings'326 | '/settings'
316 | '/usage'327 | '/usage'
317 | '/welcome'328 | '/welcome'
329 | '/_chat/new'
318 | '/_chat/pull-requests'330 | '/_chat/pull-requests'
319 | '/projects/$projectKey'331 | '/projects/$projectKey'
320 | '/settings/appearance'332 | '/settings/appearance'
...@@ -517,6 +529,13 @@ declare module '@tanstack/react-router' {...@@ -517,6 +529,13 @@ declare module '@tanstack/react-router' {
517 preLoaderRoute: typeof ChatPullRequestsRouteImport529 preLoaderRoute: typeof ChatPullRequestsRouteImport
518 parentRoute: typeof ChatRoute530 parentRoute: typeof ChatRoute
519 }531 }
532 '/_chat/new': {
533 id: '/_chat/new'
534 path: '/new'
535 fullPath: '/new'
536 preLoaderRoute: typeof ChatNewRouteImport
537 parentRoute: typeof ChatRoute
538 }
520 '/_chat/draft/$draftId': {539 '/_chat/draft/$draftId': {
521 id: '/_chat/draft/$draftId'540 id: '/_chat/draft/$draftId'
522 path: '/draft/$draftId'541 path: '/draft/$draftId'
...@@ -535,6 +554,7 @@ declare module '@tanstack/react-router' {...@@ -535,6 +554,7 @@ declare module '@tanstack/react-router' {
535}554}
536555
537interface ChatRouteChildren {556interface ChatRouteChildren {
557 ChatNewRoute: typeof ChatNewRoute
538 ChatPullRequestsRoute: typeof ChatPullRequestsRoute558 ChatPullRequestsRoute: typeof ChatPullRequestsRoute
539 ChatIndexRoute: typeof ChatIndexRoute559 ChatIndexRoute: typeof ChatIndexRoute
540 ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute560 ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute
...@@ -542,6 +562,7 @@ interface ChatRouteChildren {...@@ -542,6 +562,7 @@ interface ChatRouteChildren {
542}562}
543563
544const ChatRouteChildren: ChatRouteChildren = {564const ChatRouteChildren: ChatRouteChildren = {
565 ChatNewRoute: ChatNewRoute,
545 ChatPullRequestsRoute: ChatPullRequestsRoute,566 ChatPullRequestsRoute: ChatPullRequestsRoute,
546 ChatIndexRoute: ChatIndexRoute,567 ChatIndexRoute: ChatIndexRoute,
547 ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute,568 ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute,
apps/web/src/routes/__root.tsx+15
...@@ -67,6 +67,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme";...@@ -67,6 +67,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme";
67import { configureClientTracing } from "../observability/clientTracing";67import { configureClientTracing } from "../observability/clientTracing";
68import { resolveInitialServerAuthGateState } from "../environments/primary";68import { resolveInitialServerAuthGateState } from "../environments/primary";
69import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";69import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
70import { useHostedPromptLinkLanding } from "../components/promptLinks/HostedPromptLinkLanding";
70import { isLocalEnvironmentDisabled } from "../localEnvironment";71import { isLocalEnvironmentDisabled } from "../localEnvironment";
71import { useAtomValue } from "@effect/atom-react";72import { useAtomValue } from "@effect/atom-react";
72import { useEnvironments, usePrimaryEnvironment } from "../state/environments";73import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
...@@ -145,6 +146,9 @@ function RootRouteView() {...@@ -145,6 +146,9 @@ function RootRouteView() {
145 const { authGateState } = Route.useRouteContext();146 const { authGateState } = Route.useRouteContext();
146 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";147 const primaryEnvironmentAuthenticated = authGateState.status === "authenticated";
147 const returningFromWelcomeRef = useRef(pathname === "/welcome");148 const returningFromWelcomeRef = useRef(pathname === "/welcome");
149 const hostedPromptLinkLanding = useHostedPromptLinkLanding(
150 authGateState.status === "hosted-static",
151 );
148152
149 useEffect(() => {153 useEffect(() => {
150 if (pathname === "/welcome") {154 if (pathname === "/welcome") {
...@@ -170,6 +174,17 @@ function RootRouteView() {...@@ -170,6 +174,17 @@ function RootRouteView() {
170 );174 );
171 }175 }
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
173 // Show onboarding over the workspace, keeping automatic thread navigation188 // Show onboarding over the workspace, keeping automatic thread navigation
174 // and other startup dialogs suspended until setup finishes.189 // and other startup dialogs suspended until setup finishes.
175 if (pathname === "/welcome") {190 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";...@@ -5,6 +5,7 @@ import { useEffect, useMemo } from "react";
55
6import { isCommandPaletteOpen } from "../commandPaletteBus";6import { isCommandPaletteOpen } from "../commandPaletteBus";
7import { ThreadRouteView } from "../components/ThreadRouteView";7import { ThreadRouteView } from "../components/ThreadRouteView";
8import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt";
8import { resolveThreadRouteTarget } from "../threadRoutes";9import { resolveThreadRouteTarget } from "../threadRoutes";
9import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";10import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
10import { openCommandPalette } from "../commandPaletteBus";11import { openCommandPalette } from "../commandPaletteBus";
...@@ -224,6 +225,9 @@ function ChatRouteLayout() {...@@ -224,6 +225,9 @@ function ChatRouteLayout() {
224 return (225 return (
225 <>226 <>
226 <ChatRouteGlobalShortcuts />227 <ChatRouteGlobalShortcuts />
228 {threadTarget?.kind === "server" ? (
229 <ThreadLinkPrompt threadRef={threadTarget.threadRef} />
230 ) : null}
227 {threadTarget ? <ThreadRouteView target={threadTarget} /> : <Outlet />}231 {threadTarget ? <ThreadRouteView target={threadTarget} /> : <Outlet />}
228 </>232 </>
229 );233 );
apps/web/src/state/shell.ts+41-11
...@@ -49,6 +49,23 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => {...@@ -49,6 +49,23 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => {
49 return true;49 return true;
50}).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped"));50}).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
52/** Cached or missing snapshots cannot establish that a saved project no longer exists. */69/** Cached or missing snapshots cannot establish that a saved project no longer exists. */
53export function createAllEnvironmentProjectSnapshotsReadyAtom(input: {70export function createAllEnvironmentProjectSnapshotsReadyAtom(input: {
54 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;71 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
...@@ -57,17 +74,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: {...@@ -57,17 +74,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: {
57}) {74}) {
58 return Atom.make((get) => {75 return Atom.make((get) => {
59 const catalog = get(input.catalogValueAtom);76 const catalog = get(input.catalogValueAtom);
60 // The persisted catalog can emit before platform discovery registers the77 if (!catalogIsComplete(catalog, input.requiresPrimaryEnvironment)) return false;
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 }
71 for (const environmentId of enabledEnvironmentIds(catalog)) {78 for (const environmentId of enabledEnvironmentIds(catalog)) {
72 const shell = get(input.shellStateValueAtom(environmentId));79 const shell = get(input.shellStateValueAtom(environmentId));
73 if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false;80 if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false;
...@@ -82,3 +89,26 @@ export const allEnvironmentProjectSnapshotsReadyAtom =...@@ -82,3 +89,26 @@ export const allEnvironmentProjectSnapshotsReadyAtom =
82 shellStateValueAtom: environmentShell.stateValueAtom,89 shellStateValueAtom: environmentShell.stateValueAtom,
83 requiresPrimaryEnvironment: !isHostedStaticApp(),90 requiresPrimaryEnvironment: !isHostedStaticApp(),
84 });91 });
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....@@ -8,6 +8,7 @@ page, so you can ask an agent about it right away.
8Choose **T3 Code** under **System Settings → Desktop & Dock → Default web browser**. To go back,8Choose **T3 Code** under **System Settings → Desktop & Dock → Default web browser**. To go back,
9choose another browser there.9choose 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
13A link that launches T3 Code waits until the app has connected to its environment, then opens.14A 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 @@...@@ -39,6 +39,10 @@
39 "types": "./src/userMessage.ts",39 "types": "./src/userMessage.ts",
40 "default": "./src/userMessage.ts"40 "default": "./src/userMessage.ts"
41 },41 },
42 "./prompt-links": {
43 "types": "./src/promptLinks.ts",
44 "default": "./src/promptLinks.ts"
45 },
42 "./secret-request": {46 "./secret-request": {
43 "types": "./src/secretRequest.ts",47 "types": "./src/secretRequest.ts",
44 "default": "./src/secretRequest.ts"48 "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 @@...@@ -251,6 +251,10 @@
251 "types": "./src/assistantCitations.ts",251 "types": "./src/assistantCitations.ts",
252 "import": "./src/assistantCitations.ts"252 "import": "./src/assistantCitations.ts"
253 },253 },
254 "./promptLinks": {
255 "types": "./src/promptLinks.ts",
256 "import": "./src/promptLinks.ts"
257 },
254 "./threadLinks": {258 "./threadLinks": {
255 "types": "./src/threadLinks.ts",259 "types": "./src/threadLinks.ts",
256 "import": "./src/threadLinks.ts"260 "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}