From 5fe7568e871c0310c83fac0484fb84bba14d9148 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 11 Oct 2026 23:40:19 -0700 Subject: [PATCH] feat: open T3 Code from a link with a prompt ready to send (#18454) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/app/DesktopClerk.test.ts | 13 +- apps/desktop/src/app/DesktopClerk.ts | 24 +- apps/desktop/src/app/DesktopWebLinks.ts | 3 +- apps/mobile/src/components/CopyTextButton.tsx | 3 + .../projects/AddProjectDestinationRoute.tsx | 2 + .../features/projects/AddProjectScreen.tsx | 35 +- .../prompt-links/prompt-link-actions.ts | 50 +++ .../prompt-links/prompt-link-model.test.ts | 76 ++++ .../prompt-links/prompt-link-model.ts | 92 +++++ .../prompt-links/usePromptLinkResolver.ts | 313 +++++++++++++++ .../prompt-links/useThreadPromptLink.ts | 46 +++ .../features/threads/NewTaskRouteScreen.tsx | 8 + .../src/features/threads/ThreadFeed.tsx | 47 ++- .../features/threads/ThreadRouteScreen.tsx | 4 + apps/web/src/components/CommandPalette.tsx | 59 +++ .../src/components/chat/MessageCopyButton.tsx | 11 +- .../src/components/chat/MessagesTimeline.tsx | 26 +- .../desktop/DesktopWebLinkCoordinator.tsx | 14 +- .../promptLinks/HostedPromptLinkLanding.tsx | 179 +++++++++ .../promptLinks/NewThreadLinkView.tsx | 379 ++++++++++++++++++ .../promptLinks/ThreadLinkPrompt.tsx | 50 +++ .../components/promptLinks/copyPromptLink.ts | 88 ++++ .../components/promptLinks/linkedPrompt.ts | 56 +++ apps/web/src/hostedPairing.ts | 3 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 15 + apps/web/src/routes/_chat.new.tsx | 30 ++ apps/web/src/routes/_chat.tsx | 4 + apps/web/src/state/shell.ts | 52 ++- docs/user/default-browser.md | 3 +- docs/user/prompt-links.md | 56 +++ packages/client-runtime/package.json | 4 + .../client-runtime/src/promptLinks.test.ts | 114 ++++++ packages/client-runtime/src/promptLinks.ts | 105 +++++ packages/shared/package.json | 4 + packages/shared/src/promptLinks.test.ts | 126 ++++++ packages/shared/src/promptLinks.ts | 249 ++++++++++++ 37 files changed, 2325 insertions(+), 39 deletions(-) create mode 100644 apps/mobile/src/features/prompt-links/prompt-link-actions.ts create mode 100644 apps/mobile/src/features/prompt-links/prompt-link-model.test.ts create mode 100644 apps/mobile/src/features/prompt-links/prompt-link-model.ts create mode 100644 apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts create mode 100644 apps/mobile/src/features/prompt-links/useThreadPromptLink.ts create mode 100644 apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx create mode 100644 apps/web/src/components/promptLinks/NewThreadLinkView.tsx create mode 100644 apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx create mode 100644 apps/web/src/components/promptLinks/copyPromptLink.ts create mode 100644 apps/web/src/components/promptLinks/linkedPrompt.ts create mode 100644 apps/web/src/routes/_chat.new.tsx create mode 100644 docs/user/prompt-links.md create mode 100644 packages/client-runtime/src/promptLinks.test.ts create mode 100644 packages/client-runtime/src/promptLinks.ts create mode 100644 packages/shared/src/promptLinks.test.ts create mode 100644 packages/shared/src/promptLinks.ts diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index b4aef409337e0ffb1dc0004bdef261076e5f32b9..b3aa2d14a0f721112098c9d774d1ef86fbbfa5c1 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -396,7 +396,7 @@ it.effect.each(["startup", "open-url"] as const)( }).pipe(Effect.scoped), ); -it.effect("hands a web link to the renderer and leaves other links alone", () => +it.effect("hands web and app links to the renderer and leaves other links alone", () => Effect.gen(function* () { storageMock.mockReturnValue(storageAdapter); 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", () => // macOS hands the default browser every web link. assert.strictEqual(open("https://example.com/page"), 1); assert.strictEqual(open("http://localhost:3000/"), 1); + // Prompt and thread links ride the same queue; the renderer routes them in the app. + assert.strictEqual(open("t3code://new?prompt=hi"), 1); + assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1); // Anything else is not a web page; Electron keeps its own handling. assert.strictEqual(open("mailto:hello@example.com"), 0); + assert.strictEqual(open("t3code://somewhere-else"), 0); yield* Effect.yieldNow; - assert.deepStrictEqual(received, ["https://example.com/page", "http://localhost:3000/"]); + assert.deepStrictEqual(received, [ + "https://example.com/page", + "http://localhost:3000/", + "t3code://new?prompt=hi", + "t3code://threads/env-1/thread-1", + ]); }).pipe( Effect.provide(layerDesktopClerk(true, [], "darwin")), Effect.provideService(HostProcess.Arguments, ["t3"]), diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 08d3def1a6892ac87aee936eec52a6b8728aafbe..10eb76d7d9a5b5ebae9ea178cc00d510e7c04fdf 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -12,6 +12,7 @@ import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/code import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; +import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -188,8 +189,15 @@ export const make = Effect.gen(function* () { ); return true; }; + // Prompt links and thread links (`t3code://new?…`, `t3code://threads/…`) reach the + // renderer through the same queue as web links; it routes them in the app. + const openAppLink = (value: string | undefined) => { + if (!value || promptLinkAppPath(value) === null) return false; + void runPromise(webLinks.receive(value)); + return true; + }; const args = yield* HostProcess.Arguments; - args.some((value) => startProviderAuthHandoff(value)); + args.some((value) => startProviderAuthHandoff(value) || openAppLink(value)); // As the default browser, macOS hands T3 Code every web link through the same event. const openWebLink = (url: string) => { if (!DesktopWebLinks.isWebLink(url)) return false; @@ -197,7 +205,12 @@ export const make = Effect.gen(function* () { return true; }; yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { - if (startProviderAuthHandoff(url) || resumeProviderAuth(url) || openWebLink(url)) + if ( + startProviderAuthHandoff(url) || + resumeProviderAuth(url) || + openWebLink(url) || + openAppLink(url) + ) event.preventDefault(); }); // A browser opens HTML files too, which macOS hands over by path. @@ -207,7 +220,12 @@ export const make = Effect.gen(function* () { void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href)); }); yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { - if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value))) + if ( + argv?.some( + (value) => + startProviderAuthHandoff(value) || resumeProviderAuth(value) || openAppLink(value), + ) + ) return; void runPromise( Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index 44d90534d0090022cd5cec9731be7aea24e16f13..2031ada45a12dbac028fe52800e44c6b52748141 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -18,7 +18,8 @@ export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path); /** * Web links and HTML files macOS opens with T3 Code once it is the default - * browser. Files arrive as file:// URLs. They can + * browser, plus `t3code://` prompt and thread links on every platform. Files + * arrive as file:// URLs. They can * arrive before the app is ready (the link that launched it) or while the web * app reloads, so they wait until the renderer says it is listening. */ diff --git a/apps/mobile/src/components/CopyTextButton.tsx b/apps/mobile/src/components/CopyTextButton.tsx index a728cc1c18b5ab1cef40742f656dd1be4ed3a97f..f208a6a6c9b737f325084692329177885c8899b0 100644 --- a/apps/mobile/src/components/CopyTextButton.tsx +++ b/apps/mobile/src/components/CopyTextButton.tsx @@ -10,6 +10,8 @@ export const CopyTextButton = memo(function CopyTextButton(props: { readonly accessibilityLabel: string; readonly text: string; readonly onCopy?: () => Promise; + /** Lets a long-press menu wrap the button (`ControlPillMenu` injects it on Android). */ + readonly onLongPress?: () => void; readonly tintColor?: ColorValue; readonly copiedTintColor?: ColorValue; readonly backgroundColor?: ColorValue; @@ -35,6 +37,7 @@ export const CopyTextButton = memo(function CopyTextButton(props: { accessibilityLabel={copied ? "Copied" : props.accessibilityLabel} disabled={props.text.length === 0} hitSlop={8} + onLongPress={props.onLongPress} onPress={async () => { try { if (props.onCopy) await props.onCopy(); diff --git a/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx b/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx index 59b0e6c569a8c796071dfc31ea5df7cb0742dad9..8cb2dbc36e555bcad61b196fc3b610bbc96f21fa 100644 --- a/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx @@ -7,6 +7,8 @@ type AddProjectDestinationRouteParams = { readonly remoteUrl?: string | string[]; readonly repositoryTitle?: string | string[]; readonly repositoryName?: string | string[]; + /** A prompt link's prompt, for the draft the clone opens. */ + readonly prompt?: string | string[]; }; export function AddProjectDestinationRoute({ diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 88201bf359c4abd8352e06e5ff97002d403c369a..39fc4e23e0c0a585d4ea56ac47cb57309e208777 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -85,6 +85,11 @@ import { useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; import { resolveAddProjectEnvironment } from "./AddProjectScreen.logic"; +import { + createNewTaskDraft, + setComposerDraftText, + waitForComposerDraftsLoaded, +} from "../../state/use-composer-drafts"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -676,13 +681,31 @@ export function AddProjectSourceScreen() { function openNewTaskDraft( navigation: { dispatch: (action: ReturnType) => void }, - params: { environmentId: EnvironmentId; projectId: ProjectId; title: string; cloning?: "1" }, + params: { + environmentId: EnvironmentId; + projectId: ProjectId; + title: string; + cloning?: "1"; + draftId?: string; + }, ) { navigation.dispatch( CommonActions.reset({ index: 0, routes: [{ name: "NewTaskDraft", params }] }), ); } +/** A new task draft in the project, holding a prompt link's prompt. */ +async function draftWithPrompt( + environmentId: EnvironmentId, + projectId: ProjectId, + prompt: string, +): Promise { + await waitForComposerDraftsLoaded(); + const draftId = createNewTaskDraft({ environmentId, projectId }); + setComposerDraftText(draftId, prompt); + return draftId; +} + function useCreateProject(environment: EnvironmentOption | null) { const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); @@ -1302,6 +1325,7 @@ export function AddProjectDestinationScreen(props: { readonly remoteUrl?: string | string[]; readonly repositoryTitle?: string | string[]; readonly repositoryName?: string | string[]; + readonly prompt?: string | string[]; }) { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, @@ -1323,6 +1347,7 @@ export function AddProjectDestinationScreen(props: { const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); const repositoryTitle = stringParam(props.repositoryTitle); + const prompt = stringParam(props.prompt); // A lookup derives this from "owner/repo", a pasted clone URL from its own // last segment. Older links without the param keep the browsed folder. // Trim once here: the path input and the folder-list filter must compare the @@ -1394,6 +1419,13 @@ export function AddProjectDestinationScreen(props: { projectId, title, cloning: "1", + ...(prompt + ? // Without its prompt the draft still opens on the cloned project. + await draftWithPrompt(environment.environmentId, projectId, prompt).then( + (draftId) => ({ draftId }), + () => ({}), + ) + : {}), }); } } @@ -1424,6 +1456,7 @@ export function AddProjectDestinationScreen(props: { isSubmitting, navigation, pathInput, + prompt, remoteUrl, startProjectClone, ]); diff --git a/apps/mobile/src/features/prompt-links/prompt-link-actions.ts b/apps/mobile/src/features/prompt-links/prompt-link-actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..c46e85afc07b958b162cdecc0770166d087f417b --- /dev/null +++ b/apps/mobile/src/features/prompt-links/prompt-link-actions.ts @@ -0,0 +1,50 @@ +import { buildNewThreadLink, type LinkedPrompt } from "@t3tools/shared/promptLinks"; +import { Alert } from "react-native"; + +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { + getComposerDraftSnapshot, + setComposerDraftText, + waitForComposerDraftsLoaded, +} from "../../state/use-composer-drafts"; +import { linkedThreadDraftAction } from "./prompt-link-model"; + +/** Shows the one notice a prompt link owes the user, built with `promptLinkNotice`. */ +export function showPromptLinkNotice(notice: string): void { + Alert.alert("Prompt from a link", notice); +} + +/** + * Puts a linked prompt into an existing thread's composer draft. An empty composer is filled; + * typed text is replaced only when the user says so. The notice rides in the same alert, so a + * link never stacks two dialogs. + */ +export async function applyLinkedPromptToThreadDraft( + draftKey: string, + prompt: LinkedPrompt, + notice: string, +): Promise { + try { + await waitForComposerDraftsLoaded(); + } catch { + Alert.alert("Could not open link", "Drafts could not be loaded, so the prompt was not added."); + return; + } + const action = linkedThreadDraftAction(getComposerDraftSnapshot(draftKey).text, prompt.text); + if (action !== "ask") { + if (action === "fill") setComposerDraftText(draftKey, prompt.text); + showPromptLinkNotice(notice); + return; + } + Alert.alert("Linked prompt ready. Replace draft?", notice, [ + { text: "Keep mine", style: "cancel" }, + { text: "Replace", onPress: () => setComposerDraftText(draftKey, prompt.text) }, + ]); +} + +/** Copies a shareable link that opens a new task with this prompt. Used by sent user messages. */ +export async function copyPromptLink(prompt: string): Promise { + if (!(await tryCopyTextWithHaptic(buildNewThreadLink({ prompt }), { target: "prompt link" }))) { + Alert.alert("Could not copy", "Try again."); + } +} diff --git a/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts b/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..80693c1aa1f17d36239eb5592d52c994e72923fc --- /dev/null +++ b/apps/mobile/src/features/prompt-links/prompt-link-model.test.ts @@ -0,0 +1,76 @@ +import { parseNewThreadLink } from "@t3tools/shared/promptLinks"; +import { describe, expect, it } from "vite-plus/test"; + +import { + latestThreadEnvironmentIds, + linkedThreadDraftAction, + promptLinkNotice, + promptLinkScratchEnvironmentId, + promptLinkSearchFromParams, +} from "./prompt-link-model"; + +describe("promptLinkSearchFromParams", () => { + it("round-trips decoded route params into the shared parser", () => { + const search = promptLinkSearchFromParams({ + prompt: "Fix the bug & add tests = done?", + repo: ["pingdotgg/t3code", "ignored/second"], + provider: "codex", + screen: "NewTask", + }); + + expect(parseNewThreadLink(search ?? "")).toEqual({ + ok: true, + value: { + kind: "new", + prompt: { text: "Fix the bug & add tests = done?", hiddenCharacters: 0 }, + repository: { owner: "pingdotgg", name: "t3code" }, + provider: "codex", + model: null, + }, + }); + }); + + it("is null for routes without a prompt link", () => { + expect(promptLinkSearchFromParams({ incomingShareId: "share-1" })).toBeNull(); + expect(promptLinkSearchFromParams(undefined)).toBeNull(); + }); +}); + +describe("promptLinkNotice", () => { + it("replaces the plain notice with the hidden character warning", () => { + expect(promptLinkNotice({ hiddenCharacters: 0 })).toBe( + "This prompt came from a link. Read it before you send.", + ); + expect(promptLinkNotice({ hiddenCharacters: 2, modelUnavailable: true })).toBe( + "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.", + ); + }); +}); + +describe("linkedThreadDraftAction", () => { + it("fills an empty composer and asks before replacing typed text", () => { + expect(linkedThreadDraftAction(" \n", "Do it")).toBe("fill"); + expect(linkedThreadDraftAction("Do it", "Do it")).toBe("keep"); + expect(linkedThreadDraftAction("My notes", "Do it")).toBe("ask"); + }); +}); + +describe("promptLinkScratchEnvironmentId", () => { + it("prefers the most recently active machine that offers threads without a project", () => { + expect(promptLinkScratchEnvironmentId(["a", "b", "c"], ["c", "b"])).toBe("b"); + expect(promptLinkScratchEnvironmentId(["a"], ["c"])).toBe("c"); + expect(promptLinkScratchEnvironmentId(["a"], [])).toBeNull(); + }); +}); + +describe("latestThreadEnvironmentIds", () => { + it("orders machines by their latest thread", () => { + expect( + latestThreadEnvironmentIds([ + { environmentId: "a", updatedAt: "2026-01-02T00:00:00.000Z" }, + { environmentId: "b", updatedAt: "2026-01-03T00:00:00.000Z" }, + { environmentId: "a", updatedAt: "2026-01-04T00:00:00.000Z" }, + ]), + ).toEqual(["a", "b"]); + }); +}); diff --git a/apps/mobile/src/features/prompt-links/prompt-link-model.ts b/apps/mobile/src/features/prompt-links/prompt-link-model.ts new file mode 100644 index 0000000000000000000000000000000000000000..e190114e350e45d62415f4919544c058b8901a1c --- /dev/null +++ b/apps/mobile/src/features/prompt-links/prompt-link-model.ts @@ -0,0 +1,92 @@ +import type { PromptLinkRepository } from "@t3tools/shared/promptLinks"; + +/** The query keys a prompt link adds to the `new` and thread routes. */ +export const PROMPT_LINK_PARAM_KEYS = ["prompt", "repo", "pr", "provider", "model"] as const; + +type RouteParams = Readonly> | undefined; + +/** + * Rebuilds the link's query from React Navigation route params, so the shared parser reads the + * same input on every surface. Null when the route carries no prompt link. + */ +export function promptLinkSearchFromParams( + params: RouteParams, + keys: ReadonlyArray = PROMPT_LINK_PARAM_KEYS, +): string | null { + const search = new URLSearchParams(); + for (const key of keys) { + const raw = params?.[key]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (typeof value === "string") search.set(key, value); + } + return search.size > 0 ? search.toString() : null; +} + +/** Route params that remove a handled prompt link, so it applies once. */ +export function clearedPromptLinkParams(keys: ReadonlyArray = PROMPT_LINK_PARAM_KEYS) { + return Object.fromEntries(keys.map((key) => [key, undefined])); +} + +/** + * The text of the one notice a prompt link shows. Every link says the prompt came from a link; + * hidden characters replace that line with a stronger warning. + */ +export function promptLinkNotice(input: { + readonly hiddenCharacters: number; + readonly modelUnavailable?: boolean; + readonly pullRequestUnavailable?: number | null; +}): string { + return [ + input.hiddenCharacters > 0 + ? "This prompt had hidden characters, it may be malicious. They were removed. Read it before you send." + : "This prompt came from a link. Read it before you send.", + input.modelUnavailable + ? "The requested model is not available, so your default is used." + : null, + input.pullRequestUnavailable != null + ? `Pull request #${input.pullRequestUnavailable} could not be loaded, so this task starts from your default branch.` + : null, + ] + .filter((line) => line !== null) + .join("\n\n"); +} + +/** How a linked prompt lands in an existing thread composer. */ +export function linkedThreadDraftAction(currentText: string, prompt: string) { + if (currentText.trim().length === 0) return "fill" as const; + return currentText === prompt ? ("keep" as const) : ("ask" as const); +} + +/** + * The machine a prompt without a project opens on: the most recently active one that offers + * threads without a project, else the first that does. + */ +export function promptLinkScratchEnvironmentId( + environmentIdsMostRecentFirst: ReadonlyArray, + scratchEnvironmentIds: ReadonlyArray, +): Id | null { + return ( + environmentIdsMostRecentFirst.find((id) => scratchEnvironmentIds.includes(id)) ?? + scratchEnvironmentIds[0] ?? + null + ); +} + +/** Each environment once, by its most recently updated thread first. */ +export function latestThreadEnvironmentIds( + threads: ReadonlyArray<{ readonly environmentId: Id; readonly updatedAt: string }>, +): ReadonlyArray { + const latest = new Map(); + for (const thread of threads) { + if (thread.updatedAt > (latest.get(thread.environmentId) ?? "")) { + latest.set(thread.environmentId, thread.updatedAt); + } + } + return Array.from(latest.keys()).sort((left, right) => + (latest.get(right) ?? "").localeCompare(latest.get(left) ?? ""), + ); +} + +/** `owner/name`, as the user knows the repository. */ +export const promptLinkRepositoryName = (repository: PromptLinkRepository) => + `${repository.owner}/${repository.name}`; diff --git a/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..a457b5d8102d5913a25f99500fbc5337494b9598 --- /dev/null +++ b/apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts @@ -0,0 +1,313 @@ +import { StackActions, useNavigation } from "@react-navigation/native"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; +import { + findPromptLinkProject, + findPromptLinkPullRequestThread, + resolvePromptLinkModelSelection, +} from "@t3tools/client-runtime/prompt-links"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + parseNewThreadLink, + type LinkedPrompt, + type PromptLinkPullRequest, + type PromptLinkRepository, +} from "@t3tools/shared/promptLinks"; +import { useEffect, useEffectEvent, useRef } from "react"; +import { Alert } from "react-native"; + +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { useServerConfigs, useThreadShells } from "../../state/entities"; +import { projectEnvironment } from "../../state/projects"; +import { composerPullRequests } from "../../state/pull-requests"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { + createNewTaskDraft, + setComposerDraftText, + updateComposerDraftSettings, + waitForComposerDraftsLoaded, +} from "../../state/use-composer-drafts"; +import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; +import { useWorkspaceState } from "../../state/workspace"; +import { useNewTaskFlow } from "../threads/new-task-flow-provider"; +import { applyLinkedPromptToThreadDraft, showPromptLinkNotice } from "./prompt-link-actions"; +import { + clearedPromptLinkParams, + promptLinkNotice, + promptLinkRepositoryName, + latestThreadEnvironmentIds, + promptLinkScratchEnvironmentId, + promptLinkSearchFromParams, +} from "./prompt-link-model"; + +/** What a `new` link asks for, in one shape for every destination. */ +type LinkedDraftRequest = { + readonly prompt: LinkedPrompt | null; + readonly provider: string | null; + readonly model: string | null; + readonly pullRequest: PromptLinkPullRequest | null; +}; + +/** + * Opens a `t3code://new?…` link. The link lands on the New Task picker with its query as route + * params; this resolves it to a project, a thread, or a choice, then fills a draft. Nothing is + * ever sent: the user reads the prompt and presses Send. Used by `NewTaskRouteScreen`. + */ +export function usePromptLinkResolver(params: Readonly> | undefined) { + const navigation = useNavigation(); + const flow = useNewTaskFlow(); + const { state: catalogState } = useWorkspaceState(); + const threads = useThreadShells(); + const serverConfigs = useServerConfigs(); + const { connectedEnvironments } = useRemoteConnectionStatus(); + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); + const loadPullRequest = useAtomQueryRunner(composerPullRequests.detail, { + reportFailure: false, + }); + const search = promptLinkSearchFromParams(params); + // A cold launch from a link has no projects or threads yet. Matching then would offer to + // clone a repository the user already has. + const waitingForShells = + catalogState.isLoadingConnections || + catalogState.hasPendingShellSnapshot || + // A machine still connecting may hold the repository or thread. One that keeps failing to + // reconnect has settled offline, and must not hold every link. + catalogState.connectingEnvironments.some( + (environment) => + environment.connectionState === "connecting" || environment.connectionError === null, + ); + + // Writes the prompt and the requested model into a draft. Returns true when the link named a + // model the user cannot use, so their default stands in. + const writeDraft = ( + draftKey: string, + environmentId: EnvironmentId, + request: LinkedDraftRequest, + ): boolean => { + if (request.prompt) setComposerDraftText(draftKey, request.prompt.text); + if (request.provider === null) return false; + const resolved = resolvePromptLinkModelSelection( + serverConfigs.get(environmentId)?.providers ?? [], + { provider: request.provider, model: request.model }, + ); + if (resolved) updateComposerDraftSettings(draftKey, { modelSelection: resolved.selection }); + return resolved?.exact !== true; + }; + + const openProjectDraft = async (project: EnvironmentProject, request: LinkedDraftRequest) => { + try { + await waitForComposerDraftsLoaded(); + } catch { + Alert.alert( + "Could not open link", + "Drafts could not be loaded, so the prompt was not added.", + ); + return; + } + const draftKey = createNewTaskDraft({ + environmentId: project.environmentId, + projectId: project.id, + }); + let pullRequestUnavailable: number | null = null; + if (request.pullRequest) { + // The same draft the PR screen's Ask makes: Send checks out the PR head in a worktree. + const detail = await loadPullRequest({ + environmentId: project.environmentId, + input: { + projectId: project.id, + repository: promptLinkRepositoryName(request.pullRequest), + number: request.pullRequest.number, + }, + }); + if (detail._tag === "Success") { + updateComposerDraftSettings(draftKey, { + workspaceSelection: { + mode: "worktree", + branch: detail.value.headBranch, + worktreePath: null, + startFromOrigin: false, + pullRequestReference: detail.value.url, + }, + }); + } else { + pullRequestUnavailable = request.pullRequest.number; + } + } + const modelUnavailable = writeDraft(draftKey, project.environmentId, request); + navigation.dispatch( + StackActions.push("NewTaskDraft", { + draftId: draftKey, + title: request.pullRequest ? `PR #${request.pullRequest.number}` : project.title, + }), + ); + if (request.prompt || pullRequestUnavailable !== null || modelUnavailable) { + showPromptLinkNotice( + promptLinkNotice({ + hiddenCharacters: request.prompt?.hiddenCharacters ?? 0, + modelUnavailable, + pullRequestUnavailable, + }), + ); + } + }; + + const openScratchDraft = async (request: LinkedDraftRequest) => { + const environmentId = promptLinkScratchEnvironmentId( + // Machines by their latest thread, scratch threads included. + latestThreadEnvironmentIds(threads), + connectedEnvironments + .filter( + (environment) => + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, + ) + .map((environment) => environment.environmentId), + ); + if (environmentId === null) { + Alert.alert( + "Could not start without a project", + "No connected environment offers threads without a project.", + ); + return; + } + const result = await openScratch({ environmentId, input: {} }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not start without a project", + error instanceof Error + ? error.message + : "The folder for threads without a project could not be created.", + ); + } + return; + } + await openProjectDraft(result.value, { ...request, pullRequest: null }); + }; + + // Clone needs a tap on the destination screen, which then opens a draft in the cloned project + // holding the prompt. A pull request's prompt belongs on its branch, which only exists once the + // clone is done, so that link, like any on an older server, has to be opened again. + const cloneRepository = (repository: PromptLinkRepository, request: LinkedDraftRequest) => { + const environmentId = + flow.selectedEnvironmentId ?? connectedEnvironments[0]?.environmentId ?? null; + if (environmentId === null) { + Alert.alert("Could not clone", "Connect an environment first."); + return; + } + // Only a tracked clone opens a draft the prompt can ride into; older servers do not. + const prompt = + request.pullRequest === null && + serverConfigs.get(environmentId)?.environment.capabilities.projectCloneTracking === true + ? request.prompt + : null; + const name = promptLinkRepositoryName(repository); + navigation.dispatch( + StackActions.push("AddProjectDestination", { + environmentId, + remoteUrl: `https://github.com/${name}.git`, + repositoryTitle: name, + repositoryName: repository.name, + ...(prompt ? { prompt: prompt.text } : {}), + }), + ); + if (!request.prompt) return; + showPromptLinkNotice( + [ + promptLinkNotice({ + hiddenCharacters: request.prompt.hiddenCharacters, + modelUnavailable: prompt !== null && request.provider !== null, + }), + prompt + ? "The prompt opens in a new task once the clone starts." + : "Open the link again after the clone to get the prompt.", + ].join("\n\n"), + ); + }; + + const resolveLink = useEffectEvent((linkSearch: string) => { + const parsed = parseNewThreadLink(linkSearch); + if (!parsed.ok) { + Alert.alert("Could not open link", parsed.error); + return; + } + const link = parsed.value; + const request: LinkedDraftRequest = + link.kind === "new" + ? { prompt: link.prompt, provider: link.provider, model: link.model, pullRequest: null } + : { prompt: link.prompt, provider: null, model: null, pullRequest: link.pullRequest }; + + if (link.kind === "pull-request") { + const thread = findPromptLinkPullRequestThread(threads, link.pullRequest); + if (thread) { + (navigation.getParent() ?? navigation).dispatch( + StackActions.replace("Thread", { + environmentId: String(thread.environmentId), + threadId: String(thread.id), + }), + ); + if (link.prompt) { + void applyLinkedPromptToThreadDraft( + scopedThreadKey(thread.environmentId, thread.id), + link.prompt, + promptLinkNotice({ hiddenCharacters: link.prompt.hiddenCharacters }), + ); + } + return; + } + } + + const repository = link.kind === "new" ? link.repository : link.pullRequest; + if (repository === null) { + void openScratchDraft(request); + return; + } + // Most recently active first, so the user lands where they last worked. Scopes are ordered + // that way, but the checkouts inside one are not. + const lastActivity = new Map(); + for (const thread of threads) { + const key = `${thread.environmentId}:${thread.projectId}`; + if (thread.updatedAt > (lastActivity.get(key) ?? "")) lastActivity.set(key, thread.updatedAt); + } + const activityOf = (project: EnvironmentProject) => + lastActivity.get(`${project.environmentId}:${project.id}`) ?? ""; + const project = findPromptLinkProject( + flow.projectScopes + .flatMap((scope) => scope.projects) + .sort((left, right) => activityOf(right).localeCompare(activityOf(left))), + repository, + ); + if (project) { + void openProjectDraft(project, request); + return; + } + Alert.alert(`You don't have ${promptLinkRepositoryName(repository)}`, undefined, [ + { text: "Cancel", style: "cancel" }, + ...(request.prompt + ? [{ text: "Use without project", onPress: () => void openScratchDraft(request) }] + : []), + { text: "Clone", onPress: () => cloneRepository(repository, request) }, + ]); + }); + + // Handle each link once. Clearing the params lets the same link open again later. + const handledSearchRef = useRef(null); + useEffect(() => { + if (search === null) { + handledSearchRef.current = null; + return; + } + if (waitingForShells || handledSearchRef.current === search) return; + handledSearchRef.current = search; + navigation.setParams(clearedPromptLinkParams()); + resolveLink(search); + }, [navigation, search, waitingForShells]); +} diff --git a/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts b/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts new file mode 100644 index 0000000000000000000000000000000000000000..87c0b46dd14cb6e52ccfb086b72d01f558582b95 --- /dev/null +++ b/apps/mobile/src/features/prompt-links/useThreadPromptLink.ts @@ -0,0 +1,46 @@ +import { useNavigation } from "@react-navigation/native"; +import { parseThreadLinkPrompt } from "@t3tools/shared/promptLinks"; +import { useEffect, useRef } from "react"; +import { Alert } from "react-native"; + +import { applyLinkedPromptToThreadDraft } from "./prompt-link-actions"; +import { + clearedPromptLinkParams, + promptLinkNotice, + promptLinkSearchFromParams, +} from "./prompt-link-model"; + +const THREAD_LINK_KEYS = ["prompt"]; + +/** + * Applies the `prompt` of a `t3code://threads//?prompt=…` link to that + * thread's composer draft, once per link. Used by `ThreadRouteScreen`. + */ +export function useThreadPromptLink( + params: Readonly> | undefined, + threadDraftKey: string | null, +) { + const navigation = useNavigation(); + const search = promptLinkSearchFromParams(params, THREAD_LINK_KEYS); + const handledSearchRef = useRef(null); + useEffect(() => { + if (search === null) { + handledSearchRef.current = null; + return; + } + if (threadDraftKey === null || handledSearchRef.current === search) return; + handledSearchRef.current = search; + navigation.setParams(clearedPromptLinkParams(THREAD_LINK_KEYS)); + const parsed = parseThreadLinkPrompt(search); + if (!parsed.ok) { + Alert.alert("Could not open link", parsed.error); + return; + } + if (parsed.value === null) return; + void applyLinkedPromptToThreadDraft( + threadDraftKey, + parsed.value, + promptLinkNotice({ hiddenCharacters: parsed.value.hiddenCharacters }), + ); + }, [navigation, search, threadDraftKey]); +} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 2b4c6258bc70960e8d5d50187b6072539a87a9a5..53f14674183d4a56889a1d790915a64637f03d5e 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -31,12 +31,19 @@ import { useRemoteConnectionStatus } from "../../state/use-remote-environment-re import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { usePromptLinkResolver } from "../prompt-links/usePromptLinkResolver"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection"; type NewTaskRouteParams = { readonly incomingShareId?: string | string[]; + // A `t3code://new?…` prompt link. See usePromptLinkResolver. + readonly prompt?: string | string[]; + readonly repo?: string | string[]; + readonly pr?: string | string[]; + readonly provider?: string | string[]; + readonly model?: string | string[]; }; function deriveProjectEmptyState(catalogState: WorkspaceState): { @@ -144,6 +151,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps Promise) | undefined; + readonly tintColor: ColorValue; +}) { + const button = ( + + ); + // A link carries plain text, so context chips become their labels. + const linkText = replaceComposerContextReferences(props.text, (reference) => reference.label); + if (linkText.length > PROMPT_LINK_MAX_PROMPT_LENGTH) return button; + return ( + { + if (nativeEvent.event === "copy-prompt-link") void copyPromptLink(linkText); + }} + shouldOpenOnLongPress + > + {button} + + ); +} + function AssistantForkButton(props: { readonly environmentId: EnvironmentId; readonly iconColor: ColorValue; @@ -1310,8 +1352,7 @@ function renderFeedEntry( ) : null} {presentation.text.trim().length > 0 ? ( - ) : null} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 22caee1aadc9c0c5dcbd38bb4156b138556b68ff..5f9480bb2e9d48eef58433af4fd6485e7990ef09 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -86,6 +86,7 @@ import { type ThreadInspectorMode, } from "./thread-inspector-content-stack"; import { threadRouteIsHydrating } from "./thread-route-hydration"; +import { useThreadPromptLink } from "../prompt-links/useThreadPromptLink"; import { ThreadHeader } from "./ThreadHeader"; import { useAndroidWorkspaceBack, @@ -151,6 +152,8 @@ function OpeningThreadLoadingScreen() { type ThreadRouteScreenRouteProps = StaticScreenProps<{ readonly environmentId: string; readonly threadId: string; + /** A prompt link's draft text. See useThreadPromptLink. */ + readonly prompt?: string | string[]; }>; interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { @@ -210,6 +213,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); const selectedThreadDetailState = useSelectedThreadDetailState(); + useThreadPromptLink(params, routeThreadKey); if (environmentId === null || threadIdRaw === null) { return ; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index e2071ccf8847a7cc266e305547b66c70768e87de..7280f7457baa222f597fde97742df11b0321901a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -99,6 +99,11 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { + composerPromptLink, + copyPromptLink, + promptLinkRepositoryOf, +} from "./promptLinks/copyPromptLink"; import { useClientSettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; import { useCustomThemes } from "../hooks/useCustomThemes"; @@ -641,6 +646,10 @@ function OpenCommandPaletteDialog(props: { const availableSettingsSearchItems = useAvailableSettingsSearchItems(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); + const paletteRouteTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); const projects = useProjects(); const referenceThreadRef = pathname === "/pull-requests" @@ -1861,6 +1870,56 @@ function OpenCommandPaletteDialog(props: { }); } + // Read once per open: the palette is a snapshot of the composer, not a live view of it. + const promptLinkTarget = paletteRouteTarget + ? paletteRouteTarget.kind === "server" + ? paletteRouteTarget.threadRef + : paletteRouteTarget.draftId + : null; + const promptLinkProjectRef = activeThread ?? activeDraftThread; + const promptLinkInput = + promptLinkTarget === null + ? null + : { + target: promptLinkTarget, + project: projects.find( + (project) => + project.environmentId === promptLinkProjectRef?.environmentId && + project.id === promptLinkProjectRef.projectId, + ), + providers: + environments.find( + (environment) => environment.environmentId === promptLinkProjectRef?.environmentId, + )?.serverConfig?.providers ?? [], + threadModelSelection: activeThread?.modelSelection ?? null, + }; + const plainPromptLink = promptLinkInput + ? composerPromptLink({ ...promptLinkInput, withContext: false }) + : null; + if (promptLinkInput !== null && plainPromptLink !== null) { + actionItems.push({ + kind: "action", + value: "action:copy-prompt-link", + searchTerms: ["copy", "share", "prompt", "link"], + title: "Copy prompt link", + icon: , + run: () => copyPromptLink(plainPromptLink), + }); + if (promptLinkRepositoryOf(promptLinkInput.project) !== null) { + const contextLink = composerPromptLink({ ...promptLinkInput, withContext: true }); + if (contextLink !== null) { + actionItems.push({ + kind: "action", + value: "action:copy-prompt-link-with-context", + searchTerms: ["copy", "share", "prompt", "link", "repository", "model"], + title: "Copy prompt link with repo and model", + icon: , + run: () => copyPromptLink(contextLink), + }); + } + } + } + if (activeThreadReferenceCopyTarget !== null) { actionItems.push({ kind: "action", diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index ebf12987a9881fd3d187f8bf7000b88a2bde9505..2a3aee712637d62d4329c22ee33a91443af8aed9 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -13,12 +13,17 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export const MessageCopyButton = memo(function MessageCopyButton({ text, + label = "Copy message", + icon = Copy, extraFlavors, size = "xs", variant = "outline", className, }: { text: string; + label?: string; + /** The icon shown until the copy lands. */ + icon?: typeof Copy; /** Additional clipboard types written beside `text/plain` when the platform allows it. */ extraFlavors?: Readonly>; size?: "xs" | "icon-xs"; @@ -38,7 +43,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({ copyToClipboard(text)} ref={ref} @@ -51,11 +56,11 @@ export const MessageCopyButton = memo(function MessageCopyButton({ > -

Copy message

+

{label}

); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2a45017fd057061433968117bc4f11fcc2b0b12d..bac737b66ba01438424c8c8f9693b6cd93c58ffb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -152,7 +152,7 @@ import { ZapIcon, RotateCcwIcon, } from "lucide-react"; -import { ChevronDown, ChevronRight } from "lucide"; +import { ChevronDown, ChevronRight, Link as LinkIconNode } from "lucide"; import type { ComposerContextId, ComposerContextRecord, @@ -186,6 +186,7 @@ import { timelineContentOverflowsViewport, } from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; +import { promptLinkFor } from "../promptLinks/copyPromptLink"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { WorkspaceEntryIcon, WorkspaceEntryTooltip } from "./WorkspaceEntryIcon"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; @@ -2326,6 +2327,12 @@ function UserTimelineRow({ row }: { row: Extract record.contextId), [resolvedContext.records], ); + // Chips read as their labels outside a draft, so a shared link carries the prose alone. + const plainText = useMemo( + () => replaceComposerContextReferences(resolvedContext.text, (reference) => reference.label), + [resolvedContext.text], + ); + const promptLink = useMemo(() => promptLinkFor(plainText), [plainText]); const contextClipboardFragment = resolvedContext.records.length === 0 ? null @@ -2615,14 +2622,7 @@ function UserTimelineRow({ row }: { row: Extract reference.label, - ) - } + text={contextClipboardFragment ? resolvedContext.text : plainText} {...(contextClipboardFragment ? { extraFlavors: { [COMPOSER_CONTEXT_CLIPBOARD_MIME]: contextClipboardFragment }, @@ -2631,6 +2631,14 @@ function UserTimelineRow({ row }: { row: Extract )} + {promptLink !== null && ( + + )} diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index 9ee7f216d97202d0793b6533a1b28e97b6e14442..df177a8d141f455593d6d183e59ed910162aee1b 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -1,6 +1,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; import { useRouter } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useRef } from "react"; @@ -45,9 +46,11 @@ function threadShowingBrowser( } /** - * Opens each web link or HTML file the OS hands T3 Code as the default browser - * (macOS). With a browser panel on screen the page opens as a new tab there; - * otherwise it starts a thread without a project, its browser panel maximized. + * Opens each link the OS hands T3 Code. Prompt and thread links, by `t3code://` + * or as hosted-app pages, open in the app. Other web links and HTML files arrive + * because T3 Code is the default browser (macOS): with a browser panel on screen + * the page opens as a new tab there; otherwise it starts a thread without a + * project, its browser panel maximized. */ export function DesktopWebLinkCoordinator() { const router = useRouter(); @@ -93,6 +96,11 @@ export function DesktopWebLinkCoordinator() { }; const openLink = useEffectEvent(async (url: string) => { + const appPath = promptLinkAppPath(url); + if (appPath !== null) { + await router.navigate({ href: appPath }); + return; + } const isFile = url.startsWith("file:"); const routeParams = router.state.matches.at(-1)?.params ?? {}; const threadRef = diff --git a/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77fca435850a1112d5445f44cdaab02ed4fd1f97 --- /dev/null +++ b/apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx @@ -0,0 +1,179 @@ +import { + type LinkedPrompt, + parseNewThreadLink, + parseThreadLinkPrompt, + promptLinkAppPath, + promptLinkDeepLink, +} from "@t3tools/shared/promptLinks"; +import { useLocation } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; + +import { APP_DISPLAY_NAME } from "~/branding"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { isHostedStaticApp } from "~/hostedPairing"; +import { useEnvironments } from "~/state/environments"; + +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { StandalonePage, StandalonePageHeader } from "../ui/standalone-page"; + +const OPENER_STORAGE_KEY = "t3code:prompt-link-opener"; +const Opener = Schema.NullOr(Schema.Literals(["app", "browser"])); +const DOWNLOAD_URL = "https://t3.codes/download"; + +interface LinkSummary { + readonly prompt: LinkedPrompt | null; + readonly details: ReadonlyArray; +} + +function summarize( + appPath: string, +): { ok: true; value: LinkSummary } | { ok: false; error: string } { + const url = new URL(appPath, window.location.origin); + if (url.pathname !== "/new") { + const prompt = parseThreadLinkPrompt(url.search); + return prompt.ok + ? { ok: true, value: { prompt: prompt.value, details: ["Opens one of your threads."] } } + : prompt; + } + const link = parseNewThreadLink(url.search); + if (!link.ok) return link; + if (link.value.kind === "pull-request") { + const { owner, name, number } = link.value.pullRequest; + return { + ok: true, + value: { prompt: link.value.prompt, details: [`Pull request ${owner}/${name}#${number}`] }, + }; + } + const { repository, provider, model } = link.value; + return { + ok: true, + value: { + prompt: link.value.prompt, + details: [ + repository ? `Repository ${repository.owner}/${repository.name}` : "No project", + ...(provider ? [`Model ${model ? `${provider} ${model}` : provider}`] : []), + ], + }, + }; +} + +/** + * On app.t3.codes a prompt link first asks where to open: the installed desktop or mobile app + * (through `t3code://`), this browser when it is connected to a machine, or the download page. + * Returns that page, or null when the link should open in this browser as usual. + */ +export function useHostedPromptLinkLanding(enabled: boolean): ReactNode | null { + const href = useLocation({ select: (location) => location.href }); + const { environments, isReady } = useEnvironments(); + const [opener, setOpener] = useLocalStorage(OPENER_STORAGE_KEY, null, Opener); + const [continuedHref, setContinuedHref] = useState(null); + const appPath = useMemo( + () => + enabled && isHostedStaticApp() + ? promptLinkAppPath(new URL(href, window.location.origin).href, window.location.origin) + : null, + [enabled, href], + ); + if (appPath === null || continuedHref === href) return null; + const canContinue = isReady && environments.length > 0; + if (opener === "browser") { + if (canContinue) return null; + // Blank until the machines load, so a remembered choice does not flash this page. + if (!isReady) return <>; + } + return ( + { + if (remember) setOpener(choice); + if (choice === "browser") setContinuedHref(href); + }} + /> + ); +} + +function HostedPromptLinkLanding({ + appPath, + canContinue, + openInAppRemembered, + onChoose, +}: { + readonly appPath: string; + readonly canContinue: boolean; + readonly openInAppRemembered: boolean; + readonly onChoose: (choice: "app" | "browser", remember: boolean) => void; +}) { + const summary = useMemo(() => summarize(appPath), [appPath]); + const [remember, setRemember] = useState(false); + const deepLink = promptLinkDeepLink(appPath); + const onPhone = /android|iphone|ipad/i.test(navigator.userAgent); + const autoOpenedRef = useRef(false); + + // After "remember", later links hand straight to the app. The page stays as the way back + // when the app is not installed on this device. + useEffect(() => { + if (!openInAppRemembered || !summary.ok || autoOpenedRef.current) return; + autoOpenedRef.current = true; + window.location.href = deepLink; + }, [deepLink, openInAppRemembered, summary.ok]); + + if (!summary.ok) { + return ( + + + + ); + } + + const { prompt, details } = summary.value; + return ( + + + {prompt !== null && prompt.hiddenCharacters > 0 ? ( + + This prompt had hidden characters, it may be malicious + + T3 Code removed {prompt.hiddenCharacters} hidden character + {prompt.hiddenCharacters === 1 ? "" : "s"}. Read the prompt before you send it. + + + ) : null} + {prompt !== null ? ( +
+          {prompt.text}
+        
+ ) : null} +

{details.join(" · ")}

+
+ + {canContinue ? ( + + ) : null} + +
+ +
+ ); +} diff --git a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ab7cbc4b18070386e89f8257b364cb6e62ba3a56 --- /dev/null +++ b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx @@ -0,0 +1,379 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { getCloneDestinationPath } from "@t3tools/client-runtime/operations/projects"; +import { + findPromptLinkProject, + findPromptLinkPullRequestThread, + resolvePromptLinkModelSelection, +} from "@t3tools/client-runtime/prompt-links"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + type NewThreadLink, + type PromptLinkRepository, + promptLinkPullRequestUrl, +} from "@t3tools/shared/promptLinks"; +import { useAtomValue } from "@effect/atom-react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; + +import { useComposerDraftStore } from "~/composerDraftStore"; +import { isElectron } from "~/env"; +import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useScratchProject } from "~/hooks/useScratchProject"; +import { newProjectId } from "~/lib/utils"; +import { useProjects, useThreadShells, waitForProject } from "~/state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { allReachableShellsLiveAtom } from "~/state/shell"; +import { sourceControlEnvironment } from "~/state/sourceControl"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { usePullRequestHandoffs } from "../pullRequest/usePullRequestActions"; +import { sortScopedProjectsForSidebar } from "../Sidebar.logic"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; +import { SidebarInset } from "../ui/sidebar"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { WorkspacePageHeader } from "../WorkspacePageHeader"; +import { placeLinkedPrompt, warnAboutHiddenCharacters } from "./linkedPrompt"; + +type LinkState = + | { readonly status: "opening" } + | { readonly status: "opened" } + | { readonly status: "failed"; readonly message: string; readonly title?: string } + | { readonly status: "missing-repository"; readonly repository: PromptLinkRepository } + | { + readonly status: "pull-request-checkout"; + readonly project: EnvironmentProject; + }; + +// A link opens once per history entry. Going back to it lands here again, and opening a +// second draft for the same click would surprise more than it helps. +const openedLinkKeys = new Set(); + +/** + * The `/new` route: turns a prompt link into a draft holding its prompt, in the project the + * link names (or "No project"), or into the thread behind a linked pull request. The user + * still reads the prompt and presses Send. + */ +export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) { + const locationKey = useLocation({ select: (location) => location.state.__TSR_key ?? "" }); + const navigate = useNavigate(); + // The link needs every reachable machine's projects and threads to find its repository. + const ready = useAtomValue(allReachableShellsLiveAtom); + const projects = useProjects(); + const threads = useThreadShells(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const handleNewThread = useNewThreadHandler(); + const { scratchEnvironmentId, openScratchProject } = useScratchProject(); + const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { + reportFailure: false, + }); + const [state, setState] = useState(() => + openedLinkKeys.has(locationKey) ? { status: "opened" } : { status: "opening" }, + ); + const startedRef = useRef(false); + + const recentProjects = () => + sortScopedProjectsForSidebar( + projects.filter((project) => project.isScratch !== true), + threads, + "updated_at", + ); + + /** The machine the user worked on last, scratch threads included. */ + const lastActiveEnvironmentId = () => { + let latest: (typeof threads)[number] | undefined; + for (const thread of threads) { + if (latest === undefined || thread.updatedAt > latest.updatedAt) latest = thread; + } + return latest?.environmentId ?? primaryEnvironmentId; + }; + + /** Opens a draft in the project with the link's prompt and, when it is available, model. */ + const openDraft = async (project: EnvironmentProject, extraPrompt?: string) => { + const opened = await handleNewThread(scopeProjectRef(project.environmentId, project.id), { + replace: true, + }).catch(() => null); + if (opened === null) { + setState({ status: "failed", message: "T3 Code could not open a new thread." }); + return; + } + setState({ status: "opened" }); + const notes: Array = []; + if (link.kind === "new" && link.provider !== null) { + const providers = + environments.find((environment) => environment.environmentId === project.environmentId) + ?.serverConfig?.providers ?? []; + const resolved = resolvePromptLinkModelSelection(providers, { + provider: link.provider, + model: link.model, + }); + if (resolved !== null) { + useComposerDraftStore.getState().setModelSelection(opened.draftId, resolved.selection, { + explicit: true, + replaceOptions: true, + }); + } + if (resolved === null || !resolved.exact) { + notes.push( + resolved === null + ? `The link asked for ${link.provider}, which is not set up here, so your default model is used.` + : `The link asked for ${link.model}, which ${link.provider} does not offer here, so its default model is used.`, + ); + } + } + const text = [link.prompt?.text, extraPrompt].filter(Boolean).join("\n\n"); + if (text.length === 0) return; + placeLinkedPrompt( + opened.draftId, + { text, hiddenCharacters: link.prompt?.hiddenCharacters ?? 0 }, + notes, + ); + }; + + /** "No project" on the machine the user last worked on. */ + const openWithoutProject = async () => { + setState({ status: "opening" }); + // The machine the user last worked on, or the only one that offers it. + const environmentId = + scratchEnvironmentId(lastActiveEnvironmentId()) ?? scratchEnvironmentId(null); + // Reports its own failure. + const project = environmentId === null ? null : await openScratchProject(environmentId); + if (project === null) { + setState({ + status: "failed", + message: "None of your machines can start a thread without a project right now.", + }); + return; + } + // Without its repository, a pull request link still says which one it meant. + await openDraft( + project, + link.kind === "pull-request" ? promptLinkPullRequestUrl(link.pullRequest) : undefined, + ); + }; + + const cloneRepository = async (repository: PromptLinkRepository) => { + const environmentId = lastActiveEnvironmentId(); + const environment = environments.find((entry) => entry.environmentId === environmentId); + const newProjectsRoot = environment?.serverConfig?.newProjectsRoot ?? null; + if ( + environmentId === null || + newProjectsRoot === null || + environment?.serverConfig?.environment.capabilities.projectCloneTracking !== true + ) { + setState({ + status: "failed", + message: + "This machine cannot clone from a link. Update T3 Code on it, or clone the repository from the command palette.", + }); + return; + } + setState({ status: "opening" }); + const projectId = newProjectId(); + const result = await startProjectClone({ + environmentId, + input: { + projectId, + title: repository.name, + createdAt: new Date().toISOString(), + remoteUrl: `https://github.com/${repository.owner}/${repository.name}.git`, + destinationPath: getCloneDestinationPath(newProjectsRoot, repository.name), + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + setState({ status: "missing-repository", repository }); + return; + } + // The server adds the project at once and clones behind it. The draft shows the clone's + // progress and holds Send until the code is there. + const project = await waitForProject(scopeProjectRef(environmentId, projectId), 5_000).catch( + () => null, + ); + if (project === null) { + setState({ status: "failed", message: "The clone started, but its project did not appear." }); + return; + } + // A pull request checks out from the cloned repository, which is not there yet. + if (link.kind === "pull-request") { + setState({ + status: "failed", + title: `Cloning ${repository.owner}/${repository.name}`, + message: "Open this link again when the clone finishes.", + }); + return; + } + await openDraft(project); + }; + + const start = useEffectEvent(() => { + if (startedRef.current) return; + startedRef.current = true; + openedLinkKeys.add(locationKey); + if (link.kind === "pull-request") { + const thread = findPromptLinkPullRequestThread(threads, link.pullRequest); + if (thread !== undefined) { + setState({ status: "opened" }); + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + if (link.prompt !== null) placeLinkedPrompt(threadRef, link.prompt); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: thread.environmentId, threadId: thread.id }, + replace: true, + }); + return; + } + } + const repository = link.kind === "new" ? link.repository : link.pullRequest; + if (repository === null) { + void openWithoutProject(); + return; + } + const project = findPromptLinkProject(recentProjects(), repository); + if (project === undefined) { + setState({ status: "missing-repository", repository }); + return; + } + if (link.kind === "pull-request") { + setState({ status: "pull-request-checkout", project }); + return; + } + void openDraft(project); + }); + + // Runs once per link, when the shells are in. Later shell updates must not open it again. + useEffect(() => { + if (ready && state.status === "opening") start(); + }, [ready, state.status]); + + switch (state.status) { + case "opening": + return ; + case "opened": + return ( + + + + ); + case "failed": + return ( + + ); + case "missing-repository": { + const name = `${state.repository.owner}/${state.repository.name}`; + return ( + + + + + ); + } + case "pull-request-checkout": + return link.kind === "pull-request" ? ( + + ) : null; + } +} + +/** + * Asks, then checks the linked pull request out into its own worktree and opens a thread there. + * The checkout runs the project's setup script on code from the pull request, so a link alone + * never starts it. + */ +function PullRequestLinkCheckout({ + link, + project, +}: { + readonly link: Extract; + readonly project: EnvironmentProject; +}) { + const { handoff, startHandoff } = usePullRequestHandoffs({ + environmentId: project.environmentId, + detail: { + projectId: project.id, + workspaceRoot: project.workspaceRoot, + url: promptLinkPullRequestUrl(link.pullRequest), + }, + }); + const warnedRef = useRef(false); + useEffect(() => { + if (warnedRef.current || link.prompt === null) return; + warnedRef.current = true; + warnAboutHiddenCharacters(link.prompt); + }, [link.prompt]); + const { owner, name, number } = link.pullRequest; + return ( + + + + ); +} + +function LinkPage({ + title, + description, + children, +}: { + readonly title: string; + readonly description?: string; + readonly children?: ReactNode; +}) { + return ( + + {isElectron ? : null} + + + {title} + {description ? {description} : null} + {children ?
{children}
: null} +
+
+
+ ); +} diff --git a/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx b/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6fae431bb021e22b1e1faf68218959af929de4ef --- /dev/null +++ b/apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx @@ -0,0 +1,50 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { parseThreadLinkPrompt, withoutPromptLinkParams } from "@t3tools/shared/promptLinks"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { placeLinkedPrompt } from "./linkedPrompt"; + +const handledLinkKeys = new Set(); + +/** + * Moves a thread link's `?prompt=` into that thread's composer, then drops it from the URL so a + * reload or a copied address does not bring it back. Rendered beside the thread view. + */ +export function ThreadLinkPrompt({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + const navigate = useNavigate(); + const location = useLocation({ + select: (current) => ({ + key: current.state.__TSR_key ?? current.href, + pathname: current.pathname, + search: current.searchStr, + }), + structuralSharing: true, + }); + + useEffect(() => { + if (!new URLSearchParams(location.search).has("prompt")) return; + if (!handledLinkKeys.has(location.key)) { + handledLinkKeys.add(location.key); + const parsed = parseThreadLinkPrompt(location.search); + if (!parsed.ok) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open the linked prompt", + description: parsed.error, + }), + ); + } else if (parsed.value !== null) { + placeLinkedPrompt(threadRef, parsed.value); + } + } + void navigate({ + href: `${location.pathname}${withoutPromptLinkParams(location.search)}`, + replace: true, + }); + }, [location, navigate, threadRef]); + + return null; +} diff --git a/apps/web/src/components/promptLinks/copyPromptLink.ts b/apps/web/src/components/promptLinks/copyPromptLink.ts new file mode 100644 index 0000000000000000000000000000000000000000..1817cbcd6fb45798726cb92ed815e075cbec230b --- /dev/null +++ b/apps/web/src/components/promptLinks/copyPromptLink.ts @@ -0,0 +1,88 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + type ModelSelection, + repositoryGroupingKeyOf, + type ServerProvider, +} from "@t3tools/contracts"; +import { + buildNewThreadLink, + parsePromptLinkRepository, + PROMPT_LINK_MAX_PROMPT_LENGTH, + type PromptLinkRepository, +} from "@t3tools/shared/promptLinks"; + +import { type ComposerThreadTarget, useComposerDraftStore } from "~/composerDraftStore"; +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { configuredHostedAppUrl } from "~/hostedPairing"; + +import { stackedThreadToast, toastManager } from "../ui/toast"; + +/** A shareable link to a prompt on the hosted app, or null when the prompt cannot fit in one. */ +export function promptLinkFor( + prompt: string, + context?: { + readonly repository: PromptLinkRepository | null; + readonly provider: string | null; + readonly model: string | null; + }, +): string | null { + const trimmed = prompt.trim(); + if (trimmed.length === 0 || trimmed.length > PROMPT_LINK_MAX_PROMPT_LENGTH) return null; + return buildNewThreadLink({ prompt: trimmed, ...context, origin: configuredHostedAppUrl() }); +} + +/** The project's GitHub repository, the only kind a prompt link can name. */ +export function promptLinkRepositoryOf( + project: Pick | undefined, +): PromptLinkRepository | null { + const identity = project?.repositoryIdentity; + if (identity == null) return null; + const key = repositoryGroupingKeyOf(identity); + return key.startsWith("github.com/") + ? parsePromptLinkRepository(key.slice("github.com/".length)) + : null; +} + +/** + * The draft in a composer as a link: its text and, with `withContext`, the project's repository + * and the model the composer has picked. Null when the composer holds no prompt to share. + */ +export function composerPromptLink(input: { + readonly target: ComposerThreadTarget; + readonly project: EnvironmentProject | undefined; + readonly providers: ReadonlyArray; + readonly threadModelSelection: ModelSelection | null; + readonly withContext: boolean; +}): string | null { + const draft = useComposerDraftStore.getState().getComposerDraft(input.target); + if (!draft) return null; + if (!input.withContext) return promptLinkFor(draft.prompt); + const selection = + (draft.activeProvider ? draft.modelSelectionByProvider[draft.activeProvider] : undefined) ?? + input.threadModelSelection; + const provider = selection + ? (input.providers.find((candidate) => candidate.instanceId === selection.instanceId)?.driver ?? + null) + : null; + return promptLinkFor(draft.prompt, { + repository: promptLinkRepositoryOf(input.project), + provider, + model: provider === null ? null : (selection?.model ?? null), + }); +} + +/** Copies a prompt link and says so. */ +export async function copyPromptLink(link: string): Promise { + try { + if (!(await writeTextToClipboard(link))) return; + toastManager.add({ type: "success", title: "Prompt link copied" }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy the prompt link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } +} diff --git a/apps/web/src/components/promptLinks/linkedPrompt.ts b/apps/web/src/components/promptLinks/linkedPrompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..7334f591185d008438a738db9806c1c48ef6aa0e --- /dev/null +++ b/apps/web/src/components/promptLinks/linkedPrompt.ts @@ -0,0 +1,56 @@ +import type { LinkedPrompt } from "@t3tools/shared/promptLinks"; + +import { type ComposerThreadTarget, useComposerDraftStore } from "~/composerDraftStore"; + +import { toastManager } from "../ui/toast"; + +/** A warning that stays until dismissed when the link's prompt hid characters from the reader. */ +export function warnAboutHiddenCharacters(prompt: LinkedPrompt): void { + if (prompt.hiddenCharacters === 0) return; + toastManager.add({ + type: "warning", + title: "This prompt had hidden characters, it may be malicious", + description: `T3 Code removed ${prompt.hiddenCharacters} hidden character${prompt.hiddenCharacters === 1 ? "" : "s"}. Read the prompt before you send it.`, + timeout: 0, + }); +} + +/** + * Puts a linked prompt in a composer for the user to read and send. Text the user already typed + * there is never replaced without asking: a toast offers to swap it for the linked prompt. + * `notes` carry anything the link asked for and did not get, such as a model. + */ +export function placeLinkedPrompt( + target: ComposerThreadTarget, + prompt: LinkedPrompt, + notes: ReadonlyArray = [], +): void { + warnAboutHiddenCharacters(prompt); + const place = () => { + useComposerDraftStore.getState().setPrompt(target, prompt.text); + // It was not written here, so it must be read before it is sent. + toastManager.add({ + type: "info", + title: "Prompt added from a link", + description: ["Read it before you send.", ...notes].join(" "), + }); + }; + const existing = useComposerDraftStore.getState().getComposerDraft(target)?.prompt ?? ""; + if (existing.trim().length === 0 || existing === prompt.text) { + place(); + return; + } + const id = toastManager.add({ + type: "info", + title: "Linked prompt ready", + description: "This thread already has a draft. Replace it with the linked prompt?", + timeout: 0, + actionProps: { + children: "Replace draft", + onClick: () => { + toastManager.close(id); + place(); + }, + }, + }); +} diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index f3f056522bf7d490db1c2dca8587e832b7b459b5..00fcae959a789db08950363f538be428f6054c5b 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -10,7 +10,8 @@ export interface HostedPairingRequest { export type HostedAppChannel = "latest" | "nightly"; -function configuredHostedAppUrl(): string { +/** The hosted app origin shared links point at. */ +export function configuredHostedAppUrl(): string { return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 2c0b2285d77df4a5e973446d80233595dab8f981..7c0b13ef767658d5fd7cf92d28a1753f6977ec05 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as SettingsArchivedRouteImport } from './routes/settings.archived import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' +import { Route as ChatNewRouteImport } from './routes/_chat.new' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -156,6 +157,11 @@ const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ path: '/pull-requests', getParentRoute: () => ChatRoute, } as any) +const ChatNewRoute = ChatNewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => ChatRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -176,6 +182,7 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute + '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute @@ -202,6 +209,7 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute + '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute @@ -231,6 +239,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute '/welcome': typeof WelcomeRoute + '/_chat/new': typeof ChatNewRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/settings/appearance': typeof SettingsAppearanceRoute @@ -261,6 +270,7 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/welcome' + | '/new' | '/pull-requests' | '/projects/$projectKey' | '/settings/appearance' @@ -287,6 +297,7 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/welcome' + | '/new' | '/pull-requests' | '/projects/$projectKey' | '/settings/appearance' @@ -315,6 +326,7 @@ export interface FileRouteTypes { | '/settings' | '/usage' | '/welcome' + | '/_chat/new' | '/_chat/pull-requests' | '/projects/$projectKey' | '/settings/appearance' @@ -517,6 +529,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatPullRequestsRouteImport parentRoute: typeof ChatRoute } + '/_chat/new': { + id: '/_chat/new' + path: '/new' + fullPath: '/new' + preLoaderRoute: typeof ChatNewRouteImport + parentRoute: typeof ChatRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -535,6 +554,7 @@ declare module '@tanstack/react-router' { } interface ChatRouteChildren { + ChatNewRoute: typeof ChatNewRoute ChatPullRequestsRoute: typeof ChatPullRequestsRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute @@ -542,6 +562,7 @@ interface ChatRouteChildren { } const ChatRouteChildren: ChatRouteChildren = { + ChatNewRoute: ChatNewRoute, ChatPullRequestsRoute: ChatPullRequestsRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 11e58cae692d3b2bf04ff8467951954b58c5a23d..91a0a15800785c49fb802479803270ef46375c4f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -67,6 +67,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { configureClientTracing } from "../observability/clientTracing"; import { resolveInitialServerAuthGateState } from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; +import { useHostedPromptLinkLanding } from "../components/promptLinks/HostedPromptLinkLanding"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { useAtomValue } from "@effect/atom-react"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; @@ -145,6 +146,9 @@ function RootRouteView() { const { authGateState } = Route.useRouteContext(); const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; const returningFromWelcomeRef = useRef(pathname === "/welcome"); + const hostedPromptLinkLanding = useHostedPromptLinkLanding( + authGateState.status === "hosted-static", + ); useEffect(() => { if (pathname === "/welcome") { @@ -170,6 +174,17 @@ function RootRouteView() { ); } + // Ahead of first-run onboarding, which would otherwise send a new visitor to the welcome + // wizard and lose the link. + if (hostedPromptLinkLanding !== null) { + return ( + <> + + {hostedPromptLinkLanding} + + ); + } + // Show onboarding over the workspace, keeping automatic thread navigation // and other startup dialogs suspended until setup finishes. if (pathname === "/welcome") { diff --git a/apps/web/src/routes/_chat.new.tsx b/apps/web/src/routes/_chat.new.tsx new file mode 100644 index 0000000000000000000000000000000000000000..185809097a02893cfe91ce320b3bf80f0e1fe350 --- /dev/null +++ b/apps/web/src/routes/_chat.new.tsx @@ -0,0 +1,30 @@ +import { parseNewThreadLink } from "@t3tools/shared/promptLinks"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { useMemo } from "react"; + +import { NewThreadLinkView } from "../components/promptLinks/NewThreadLinkView"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { SidebarInset } from "../components/ui/sidebar"; + +// The query is read raw: the router's own search parsing turns `prompt=123` into a number. +function NewThreadLinkRoute() { + const search = useLocation({ select: (location) => location.searchStr }); + const parsed = useMemo(() => parseNewThreadLink(search), [search]); + if (!parsed.ok) { + return ( + + + + Could not open the link + {parsed.error} + + + + ); + } + return ; +} + +export const Route = createFileRoute("/_chat/new")({ + component: NewThreadLinkRoute, +}); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 29d62bea19cf139747e165f96ea90defe248b541..1c985d60c3cc4690c98518d3423d9387812414a3 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { ThreadRouteView } from "../components/ThreadRouteView"; +import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; @@ -224,6 +225,9 @@ function ChatRouteLayout() { return ( <> + {threadTarget?.kind === "server" ? ( + + ) : null} {threadTarget ? : } ); diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index d600f542782f94fed43864c9e0a381a4313e615f..4822107ca2441cd69ff2818c9b66736381bcedd4 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -49,6 +49,23 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { return true; }).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped")); +/** + * The persisted catalog can emit before platform discovery registers the primary environment. + * Neither that gap nor an empty catalog proves a project is absent. + */ +function catalogIsComplete( + catalog: EnvironmentCatalogState, + requiresPrimaryEnvironment: boolean, +): boolean { + if (!catalog.isReady || catalog.entries.size === 0) return false; + return ( + !requiresPrimaryEnvironment || + Array.from(catalog.entries.values()).some( + (entry) => entry.target._tag === "PrimaryConnectionTarget", + ) + ); +} + /** Cached or missing snapshots cannot establish that a saved project no longer exists. */ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: { readonly catalogValueAtom: Atom.Atom; @@ -57,17 +74,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: { }) { return Atom.make((get) => { const catalog = get(input.catalogValueAtom); - // The persisted catalog can emit before platform discovery registers the - // primary environment. Neither that gap nor an empty catalog proves absence. - if (!catalog.isReady || catalog.entries.size === 0) return false; - if ( - input.requiresPrimaryEnvironment && - !Array.from(catalog.entries.values()).some( - (entry) => entry.target._tag === "PrimaryConnectionTarget", - ) - ) { - return false; - } + if (!catalogIsComplete(catalog, input.requiresPrimaryEnvironment)) return false; for (const environmentId of enabledEnvironmentIds(catalog)) { const shell = get(input.shellStateValueAtom(environmentId)); if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false; @@ -82,3 +89,26 @@ export const allEnvironmentProjectSnapshotsReadyAtom = shellStateValueAtom: environmentShell.stateValueAtom, requiresPrimaryEnvironment: !isHostedStaticApp(), }); + +/** + * Every enabled machine has sent its live projects or has settled offline. Prompt links wait for + * it before they decide a repository is missing: one offline machine must not hold a link + * forever. A machine that has not started connecting yet is "available", not offline. + */ +export const allReachableShellsLiveAtom = Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + if (!catalogIsComplete(catalog, !isHostedStaticApp())) return false; + for (const environmentId of enabledEnvironmentIds(catalog)) { + const shell = get(environmentShell.stateValueAtom(environmentId)); + if (shell.status === "live" && Option.isSome(shell.snapshot)) continue; + const state = AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))); + if (Option.isNone(state)) return false; + const connection = state.value; + const settledOffline = + connection.phase === "offline" || + connection.phase === "blocked" || + (connection.phase === "backoff" && connection.attempt > 2); + if (!settledOffline) return false; + } + return true; +}).pipe(Atom.withLabel("web-all-reachable-shells-live")); diff --git a/docs/user/default-browser.md b/docs/user/default-browser.md index 23057d0661d33c0f0a708b5d012c3dd6a34dc89e..7364608d2115f72b433381f8963380ad62f8979e 100644 --- a/docs/user/default-browser.md +++ b/docs/user/default-browser.md @@ -8,6 +8,7 @@ page, so you can ask an agent about it right away. Choose **T3 Code** under **System Settings → Desktop & Dock → Default web browser**. To go back, choose another browser there. -HTML files you open from Finder open the same way. +HTML files you open from Finder open the same way. [Prompt links](prompt-links.md) to +app.t3.codes open in the app as a draft. A link that launches T3 Code waits until the app has connected to its environment, then opens. diff --git a/docs/user/prompt-links.md b/docs/user/prompt-links.md new file mode 100644 index 0000000000000000000000000000000000000000..656cf39232a80202f092a57799e61678d216226f --- /dev/null +++ b/docs/user/prompt-links.md @@ -0,0 +1,56 @@ +# Prompt links + +A prompt link opens T3 Code with a prompt ready to send. Use one to share a prompt on social +media, or to put a one-click fix in a pull request comment. + +A link never runs anything. It opens a draft. You read the prompt, then press Send. A link cannot +change your permission mode, branch, or worktree. Your own defaults apply. + +## Make a link + +Open the command palette and choose **Copy prompt link**. It copies the draft in the composer. +When the project is on GitHub, **Copy prompt link with repo and model** also adds the repository +and the model you picked. Sent messages have a **Copy prompt link** button too. + +You can also write a link by hand: + +``` +https://app.t3.codes/new?prompt=Rate%20my%20swearing +https://app.t3.codes/new?prompt=Fix%20the%20build&repo=owner/name&provider=codex&model=gpt-5.5 +https://app.t3.codes/new?pr=owner/name/123&prompt=Fix%20the%20failing%20check +``` + +| Parameter | Meaning | +| ---------- | ----------------------------------------------------------------------------- | +| `prompt` | The prompt. Required, except on pull request links. At most 8,000 characters. | +| `repo` | A GitHub repository, as `owner/name` or its URL. | +| `provider` | A provider such as `codex` or `claude`. | +| `model` | A model of that provider, by its id or name. | +| `pr` | A GitHub pull request, as `owner/name/123` or its URL. | + +Encode the prompt for a URL. In JavaScript, use `encodeURIComponent`. + +## Open a link + +On app.t3.codes, the link first asks where to open it: the desktop or mobile app, this browser +when it is connected to a machine, or the download page. Select **Remember my choice** to skip +this step next time. + +- **No `repo`**: the draft opens without a project, on the machine you used last. +- **`repo`**: the draft opens in your most recently used project for that repository. A fork + counts only for its own owner. When you do not have the repository, choose **Clone** or **Use + without project**. +- **`provider` and `model`**: used when you have that provider set up. Otherwise your default + model is used, and T3 Code tells you. +- **`pr`**: opens the thread linked to that pull request on any connected machine, with the prompt + in its composer. When no thread is linked, the draft goes on the pull request branch in a new + worktree. T3 Code checks the code out only after you confirm, because the checkout runs your + setup script on it. + +A thread link with a prompt, such as `https://app.t3.codes//?prompt=…`, +puts the prompt in that thread's composer. If you already typed a draft there, T3 Code asks +before it replaces it. + +T3 Code removes characters you cannot see, such as zero-width and hidden tag characters, because +they can hide instructions for the agent. When a prompt had them, T3 Code warns you that it may be +malicious. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index ef3163329997e3165acbc9a0db64fe6e0568e13b..0366c65b1ed2faabdb0f3f8586a81269fa744ddf 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -39,6 +39,10 @@ "types": "./src/userMessage.ts", "default": "./src/userMessage.ts" }, + "./prompt-links": { + "types": "./src/promptLinks.ts", + "default": "./src/promptLinks.ts" + }, "./secret-request": { "types": "./src/secretRequest.ts", "default": "./src/secretRequest.ts" diff --git a/packages/client-runtime/src/promptLinks.test.ts b/packages/client-runtime/src/promptLinks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c81379c7c74c5cb7ff0a9563dc8ebaaaae9aaf9b --- /dev/null +++ b/packages/client-runtime/src/promptLinks.test.ts @@ -0,0 +1,114 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type RepositoryIdentity, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + findPromptLinkProject, + findPromptLinkPullRequestThread, + resolvePromptLinkModelSelection, +} from "./promptLinks.ts"; + +const identity = (canonicalKey: string, origin?: string): RepositoryIdentity => ({ + canonicalKey, + locator: { source: "git-remote", remoteName: "origin", remoteUrl: `https://${canonicalKey}` }, + ...(origin ? { origin: { canonicalKey: origin } } : {}), +}); + +describe("findPromptLinkProject", () => { + it("takes the first exact match and never a fork of the repository", () => { + const projects = [ + { + id: "fork", + repositoryIdentity: identity("github.com/pingdotgg/t3code", "github.com/me/t3code"), + }, + { id: "none", repositoryIdentity: null }, + { id: "recent", repositoryIdentity: identity("github.com/PingDotGG/T3Code") }, + { id: "older", repositoryIdentity: identity("github.com/pingdotgg/t3code") }, + ]; + expect(findPromptLinkProject(projects, { owner: "pingdotgg", name: "t3code" })?.id).toBe( + "recent", + ); + expect(findPromptLinkProject(projects, { owner: "me", name: "t3code" })?.id).toBe("fork"); + expect(findPromptLinkProject(projects, { owner: "gitlab", name: "x" })).toBeUndefined(); + }); +}); + +describe("findPromptLinkPullRequestThread", () => { + const link = (number: number, source: ThreadPullRequestLink["source"] = "created") => + ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source, + linkedAt: "2026-01-01T00:00:00.000Z", + snapshot: null, + stack: null, + }) satisfies ThreadPullRequestLink; + + it("finds the most recently updated thread linked to the pull request", () => { + const threads = [ + { id: "old", updatedAt: "2026-01-01T00:00:00.000Z", pullRequests: [link(7)] }, + { id: "new", updatedAt: "2026-02-01T00:00:00.000Z", pullRequests: [link(7)] }, + { id: "other", updatedAt: "2026-03-01T00:00:00.000Z", pullRequests: [link(8)] }, + { + id: "dismissed", + updatedAt: "2026-04-01T00:00:00.000Z", + pullRequests: [link(7, "stack-dismissed")], + }, + ]; + const pullRequest = { owner: "PingDotGG", name: "t3code", number: 7 }; + expect(findPromptLinkPullRequestThread(threads, pullRequest)?.id).toBe("new"); + expect(findPromptLinkPullRequestThread(threads, { ...pullRequest, number: 9 })).toBeUndefined(); + }); +}); + +describe("resolvePromptLinkModelSelection", () => { + const provider = ( + instanceId: string, + driver: string, + status: "ready" | "warning" | "error" | "disabled" = "ready", + ) => ({ + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make(driver), + enabled: true, + status, + models: [ + { slug: "small", name: "Small" }, + { slug: "big", name: "Big", aliases: ["opus"], isDefault: true }, + ], + }); + + it("maps the requested provider and model onto a usable instance", () => { + const providers = [provider("broken", "claudeAgent", "error"), provider("work", "claudeAgent")]; + expect( + resolvePromptLinkModelSelection(providers, { provider: "claude", model: "small" }), + ).toEqual({ + selection: { instanceId: "work", model: "small" }, + exact: true, + }); + expect( + resolvePromptLinkModelSelection(providers, { provider: "claudeagent", model: "opus" }), + ).toEqual({ + selection: { instanceId: "work", model: "big" }, + exact: true, + }); + }); + + it("uses the instance default for an unknown model, and nothing for a missing provider", () => { + const providers = [provider("codex", "codex")]; + expect( + resolvePromptLinkModelSelection(providers, { provider: "codex", model: "nope" }), + ).toEqual({ + selection: { instanceId: "codex", model: "big" }, + exact: false, + }); + expect( + resolvePromptLinkModelSelection(providers, { provider: "cursor", model: null }), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/promptLinks.ts b/packages/client-runtime/src/promptLinks.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4c007c4c387dde5068790278129eae291043bbc --- /dev/null +++ b/packages/client-runtime/src/promptLinks.ts @@ -0,0 +1,105 @@ +import { + repositoryGroupingKeyOf, + type ModelSelection, + type RepositoryIdentity, + type ServerProvider, + type ThreadLinkedPullRequest, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { resolveSelectableModel, type SelectableModelOption } from "@t3tools/shared/model"; +import type { PromptLinkPullRequest, PromptLinkRepository } from "@t3tools/shared/promptLinks"; +import { + threadPullRequestKeyOf, + threadPullRequestsOf, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; + +/** + * How web and mobile resolve a prompt link against what this client knows. Both read the same + * shells, so a link lands in the same project and thread on every surface. + */ + +const repositoryKey = (repository: PromptLinkRepository) => + `github.com/${repository.owner}/${repository.name}`.toLowerCase(); + +/** + * The project a link's repository names. Callers pass projects most recently active first, so + * the user lands where they last worked. A fork only matches its own owner, not its upstream. + */ +export function findPromptLinkProject< + P extends { readonly repositoryIdentity?: RepositoryIdentity | null | undefined }, +>(projects: ReadonlyArray

, repository: PromptLinkRepository): P | undefined { + const key = repositoryKey(repository); + return projects.find( + (project) => + project.repositoryIdentity != null && + repositoryGroupingKeyOf(project.repositoryIdentity).toLowerCase() === key, + ); +} + +/** The most recently updated thread linked to the pull request, across every environment given. */ +export function findPromptLinkPullRequestThread< + T extends { + readonly updatedAt: string; + readonly pullRequests?: ReadonlyArray | undefined; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + }, +>(threads: ReadonlyArray, pullRequest: PromptLinkPullRequest): T | undefined { + const key = threadPullRequestKeyOf({ + host: "github.com", + repository: `${pullRequest.owner}/${pullRequest.name}`, + number: pullRequest.number, + }); + let latest: T | undefined; + for (const thread of threads) { + if (latest !== undefined && thread.updatedAt <= latest.updatedAt) continue; + const linked = visibleThreadPullRequests(threadPullRequestsOf(thread)).some( + (link) => threadPullRequestKeyOf(link) === key, + ); + if (linked) latest = thread; + } + return latest; +} + +// People write the name they know. Driver kinds are what the server reports. +const PROVIDER_ALIASES: Record = { claude: "claudeagent" }; + +/** + * The selection a link asks for, on one of the user's own provider instances. `exact` is false + * when the instance exists but the model does not, so its default model stands in. Null when + * no usable instance of that provider exists; the user's default applies then. + */ +export function resolvePromptLinkModelSelection( + providers: ReadonlyArray< + Pick & { + readonly models: ReadonlyArray< + SelectableModelOption & { readonly isDefault?: boolean | undefined } + >; + } + >, + request: { readonly provider: string; readonly model: string | null }, +): { readonly selection: ModelSelection; readonly exact: boolean } | null { + const driver = PROVIDER_ALIASES[request.provider] ?? request.provider; + const usable = providers.filter( + (provider) => + provider.driver.toLowerCase() === driver && + provider.enabled && + provider.availability !== "unavailable", + ); + const instance = + usable.find((provider) => provider.status === "ready") ?? + usable.find((provider) => provider.status === "warning"); + if (instance === undefined) return null; + const requested = + request.model === null + ? null + : resolveSelectableModel(instance.driver, request.model, instance.models); + const model = + requested ?? + (instance.models.find((candidate) => candidate.isDefault) ?? instance.models[0])?.slug; + if (model === undefined) return null; + return { + selection: { instanceId: instance.instanceId, model }, + exact: request.model === null || requested !== null, + }; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 7ad055d76ded9132bafc4c12b6180022883d8675..98c9379527675e30f2291f8a805ac6a8d48929db 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -251,6 +251,10 @@ "types": "./src/assistantCitations.ts", "import": "./src/assistantCitations.ts" }, + "./promptLinks": { + "types": "./src/promptLinks.ts", + "import": "./src/promptLinks.ts" + }, "./threadLinks": { "types": "./src/threadLinks.ts", "import": "./src/threadLinks.ts" diff --git a/packages/shared/src/promptLinks.test.ts b/packages/shared/src/promptLinks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e0256374593963948be28fb1482241d431d8923 --- /dev/null +++ b/packages/shared/src/promptLinks.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildNewThreadLink, + parseNewThreadLink, + parseThreadLinkPrompt, + promptLinkAppPath, + promptLinkDeepLink, + sanitizeLinkedPrompt, + withoutPromptLinkParams, +} from "./promptLinks.ts"; + +describe("sanitizeLinkedPrompt", () => { + it("removes characters a reader cannot see and counts them", () => { + const tagged = "rate me" + String.fromCodePoint(0xe0041, 0xe0042); + expect(sanitizeLinkedPrompt(`a\u200Bb\u202Ec\uFEFF${tagged}`)).toEqual({ + text: "abcrate me", + hiddenCharacters: 5, + }); + expect(sanitizeLinkedPrompt("a\u2028b\u2029c")).toEqual({ + text: "a\nb\nc", + hiddenCharacters: 0, + }); + }); + + it("keeps emoji sequences intact", () => { + const family = "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}"; + const heart = "\u2764\uFE0F"; + expect(sanitizeLinkedPrompt(`${family} ${heart}`)).toEqual({ + text: `${family} ${heart}`, + hiddenCharacters: 0, + }); + expect(sanitizeLinkedPrompt("a\u200Db").hiddenCharacters).toBe(1); + expect(sanitizeLinkedPrompt("\u{1F468}\u200D").hiddenCharacters).toBe(1); + expect(sanitizeLinkedPrompt("\u{20000}\u200D\u{20001}").hiddenCharacters).toBe(1); + }); +}); + +describe("parseNewThreadLink", () => { + it("reads a shared prompt with its optional repository and model", () => { + const result = parseNewThreadLink( + "?prompt=Rate+me&repo=https://github.com/pingdotgg/t3code.git&provider=Codex&model=gpt-5", + ); + expect(result).toEqual({ + ok: true, + value: { + kind: "new", + prompt: { text: "Rate me", hiddenCharacters: 0 }, + repository: { owner: "pingdotgg", name: "t3code" }, + provider: "codex", + model: "gpt-5", + }, + }); + }); + + it("reads a pull request link, whose prompt is optional", () => { + for (const pr of [ + "pingdotgg/t3code/42", + "pingdotgg/t3code%2342", + "https://github.com/pingdotgg/t3code/pull/42/files", + ]) { + expect(parseNewThreadLink(`?pr=${pr}`)).toEqual({ + ok: true, + value: { + kind: "pull-request", + pullRequest: { owner: "pingdotgg", name: "t3code", number: 42 }, + prompt: null, + }, + }); + } + }); + + it("refuses links it cannot open safely", () => { + expect(parseNewThreadLink("?repo=a/b").ok).toBe(false); + expect(parseNewThreadLink("?prompt=%E2%80%8B").ok).toBe(false); + expect(parseThreadLinkPrompt("?prompt=%E2%80%8B").ok).toBe(false); + expect(parseNewThreadLink("?prompt=x&repo=../etc").ok).toBe(false); + expect(parseNewThreadLink(`?prompt=${"x".repeat(8_001)}`).ok).toBe(false); + }); +}); + +describe("thread links", () => { + it("reads the prompt and leaves other parameters in place", () => { + expect(parseThreadLinkPrompt("?prompt=Fix+it")).toEqual({ + ok: true, + value: { text: "Fix it", hiddenCharacters: 0 }, + }); + expect(parseThreadLinkPrompt("?panel=diff")).toEqual({ ok: true, value: null }); + expect(withoutPromptLinkParams("?prompt=x&panel=diff")).toBe("?panel=diff"); + }); +}); + +describe("link round trips", () => { + it("builds a link that parses back to the same prompt", () => { + const link = buildNewThreadLink({ + prompt: "Swear count & rating?", + repository: { owner: "pingdotgg", name: "t3code" }, + provider: "claudeAgent", + model: "opus", + }); + const path = promptLinkAppPath(link); + expect(path).toBe(new URL(link).pathname + new URL(link).search); + const parsed = parseNewThreadLink(new URL(link).search); + expect(parsed.ok && parsed.value.kind === "new" && parsed.value.prompt.text).toBe( + "Swear count & rating?", + ); + }); + + it("maps app scheme and hosted links to in-app paths, and nothing else", () => { + expect(promptLinkAppPath("t3code://new?prompt=hi")).toBe("/new?prompt=hi"); + expect(promptLinkAppPath("t3code-dev://threads/env-1/thread-1")).toBe("/env-1/thread-1"); + expect(promptLinkAppPath("https://app.t3.codes/env-1/thread-1?prompt=go")).toBe( + "/env-1/thread-1?prompt=go", + ); + expect(promptLinkAppPath("https://app.t3.codes/settings/general")).toBeNull(); + expect(promptLinkAppPath("https://evil.test/new?prompt=hi")).toBeNull(); + expect(promptLinkAppPath("t3code://app/index.html")).toBeNull(); + }); + + it("hands an in-app path to the installed app", () => { + expect(promptLinkDeepLink("/new?prompt=hi")).toBe("t3code://new?prompt=hi"); + expect(promptLinkDeepLink("/env-1/thread-1?prompt=hi")).toBe( + "t3code://threads/env-1/thread-1?prompt=hi", + ); + }); +}); diff --git a/packages/shared/src/promptLinks.ts b/packages/shared/src/promptLinks.ts new file mode 100644 index 0000000000000000000000000000000000000000..a867e5b2c9a010ca036ef74a60f96ae029e769ef --- /dev/null +++ b/packages/shared/src/promptLinks.ts @@ -0,0 +1,249 @@ +import { DEFAULT_HOSTED_APP_URL } from "./connectAuth.ts"; + +/** + * Prompt links open T3 Code from outside it with a prompt ready to send. They never send it: + * a link from a stranger fills a draft, and the user reads it and presses Send. A link also + * cannot pick a runtime mode, branch, or worktree. Those stay the user's own defaults. + * + * Shared links are https on the hosted app, because that is what Twitter and GitHub make + * clickable. The `t3code://` scheme carries the same paths into desktop and mobile: + * + * https://app.t3.codes/new?prompt=…&repo=owner/name&provider=codex&model=gpt-5 + * https://app.t3.codes/new?pr=owner/name/123&prompt=… + * https://app.t3.codes//?prompt=… + * t3code://new?… t3code://threads//?… + */ + +/** URLs stay well under the limits browsers and link shorteners apply. */ +export const PROMPT_LINK_MAX_PROMPT_LENGTH = 8_000; + +export interface PromptLinkRepository { + readonly owner: string; + readonly name: string; +} + +export interface PromptLinkPullRequest extends PromptLinkRepository { + readonly number: number; +} + +/** A prompt as it arrived, after hidden characters were removed. */ +export interface LinkedPrompt { + readonly text: string; + /** How many invisible characters were removed. Any count above zero is a warning sign. */ + readonly hiddenCharacters: number; +} + +export type NewThreadLink = + | { + readonly kind: "new"; + readonly prompt: LinkedPrompt; + readonly repository: PromptLinkRepository | null; + /** A provider driver kind such as `codex`. Users map it to one of their own instances. */ + readonly provider: string | null; + readonly model: string | null; + } + | { + readonly kind: "pull-request"; + readonly pullRequest: PromptLinkPullRequest; + readonly prompt: LinkedPrompt | null; + }; + +export type PromptLinkResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; + +// Zero-width and bidi controls, tag and variation-selector blocks, and other characters that +// render as nothing. A model reads them; the person checking the draft cannot see them, which +// makes them the usual way to hide instructions in a prompt. U+FE0E/U+FE0F stay: emoji use them. +// Line and paragraph separators become plain newlines, so the draft shows the break the model reads. +const HIDDEN_CHARACTERS = + // oxlint-disable-next-line no-control-regex, no-misleading-character-class -- these are the characters it removes. + /[\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; +// Emoji and the pieces emoji sequences are built from: pictographs, symbols, skin tones, and the +// emoji presentation selector. Numeric ranges, because Hermes lacks Unicode property escapes. +const isEmojiPart = (codePoint: number | undefined) => + codePoint !== undefined && + ((codePoint >= 0x1f000 && codePoint <= 0x1faff) || + (codePoint >= 0x2190 && codePoint <= 0x2bff) || + codePoint === 0xfe0f); + +/** Removes characters a reader cannot see, and counts them. */ +export function sanitizeLinkedPrompt(raw: string): LinkedPrompt { + let hiddenCharacters = 0; + const visible = Array.from( + raw.replace(/\r\n?|[\u2028\u2029]/g, "\n").replace(HIDDEN_CHARACTERS, () => { + hiddenCharacters++; + return ""; + }), + ); + // A zero-width joiner is visible work only inside an emoji sequence, such as a family. + const text = visible + .filter((character, index) => { + if (character !== "\u200D") return true; + const kept = + isEmojiPart(visible[index - 1]?.codePointAt(0)) && + isEmojiPart(visible[index + 1]?.codePointAt(0)); + if (!kept) hiddenCharacters++; + return kept; + }) + .join(""); + return { text: text.trim(), hiddenCharacters }; +} + +const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +const NAME = /^[A-Za-z0-9._-]{1,100}$/; + +function repositoryOf(owner: string, name: string): PromptLinkRepository | null { + const trimmedName = name.replace(/\.git$/i, ""); + if (!OWNER.test(owner) || !NAME.test(trimmedName) || /^\.+$/.test(trimmedName)) return null; + return { owner, name: trimmedName }; +} + +/** `owner/name`, or a GitHub repository URL. */ +export function parsePromptLinkRepository(value: string): PromptLinkRepository | null { + const trimmed = value.trim(); + const url = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/?$/i.exec(trimmed); + const short = /^([^/\s]+)\/([^/\s]+)$/.exec(trimmed); + const match = url ?? short; + return match?.[1] && match[2] ? repositoryOf(match[1], match[2]) : null; +} + +/** `owner/name/123`, `owner/name#123`, or a GitHub pull request URL. */ +function parsePromptLinkPullRequest(value: string): PromptLinkPullRequest | null { + const trimmed = value.trim(); + const match = + /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/pull\/(\d+)(?:[/?#].*)?$/i.exec( + trimmed, + ) ?? /^([^/\s#]+)\/([^/\s#]+)(?:\/|#)(\d+)$/.exec(trimmed); + if (!match?.[1] || !match[2] || !match[3]) return null; + const repository = repositoryOf(match[1], match[2]); + const number = Number(match[3]); + return repository && Number.isSafeInteger(number) && number > 0 + ? { ...repository, number } + : null; +} + +/** The pull request's page on GitHub, the reference `preparePullRequestThread` takes. */ +export function promptLinkPullRequestUrl(pullRequest: PromptLinkPullRequest): string { + return `https://github.com/${pullRequest.owner}/${pullRequest.name}/pull/${pullRequest.number}`; +} + +function readPrompt(params: URLSearchParams): PromptLinkResult { + const raw = params.get("prompt"); + if (raw === null) return { ok: true, value: null }; + if (raw.length > PROMPT_LINK_MAX_PROMPT_LENGTH) { + return { + ok: false, + error: `This prompt is longer than ${PROMPT_LINK_MAX_PROMPT_LENGTH.toLocaleString("en-US")} characters, so T3 Code did not open it.`, + }; + } + const prompt = sanitizeLinkedPrompt(raw); + if (prompt.text.length === 0 && prompt.hiddenCharacters > 0) { + return { + ok: false, + error: + "This prompt had only hidden characters, so it may be malicious. T3 Code did not open it.", + }; + } + return { ok: true, value: prompt.text.length > 0 ? prompt : null }; +} + +const optionalParam = (params: URLSearchParams, key: string) => params.get(key)?.trim() || null; + +/** Reads the query of a `/new` link. */ +export function parseNewThreadLink(search: string): PromptLinkResult { + const params = new URLSearchParams(search); + const prompt = readPrompt(params); + if (!prompt.ok) return prompt; + const pr = params.get("pr"); + if (pr !== null) { + const pullRequest = parsePromptLinkPullRequest(pr); + if (pullRequest === null) { + return { ok: false, error: "This link names a pull request T3 Code cannot read." }; + } + return { ok: true, value: { kind: "pull-request", pullRequest, prompt: prompt.value } }; + } + if (prompt.value === null) return { ok: false, error: "This link has no prompt." }; + const repo = params.get("repo"); + const repository = repo === null ? null : parsePromptLinkRepository(repo); + if (repo !== null && repository === null) { + return { ok: false, error: "This link names a repository T3 Code cannot read." }; + } + return { + ok: true, + value: { + kind: "new", + prompt: prompt.value, + repository, + provider: optionalParam(params, "provider")?.toLowerCase() ?? null, + model: optionalParam(params, "model"), + }, + }; +} + +/** Reads the `prompt` of a thread link. Null when the link carries none. */ +export function parseThreadLinkPrompt(search: string): PromptLinkResult { + return readPrompt(new URLSearchParams(search)); +} + +/** Drops the link's own parameters from a query, keeping anything else the route uses. */ +export function withoutPromptLinkParams(search: string): string { + const params = new URLSearchParams(search); + for (const key of ["prompt", "repo", "pr", "provider", "model"]) params.delete(key); + const rest = params.toString(); + return rest ? `?${rest}` : ""; +} + +/** A shareable `/new` link on the hosted app. */ +export function buildNewThreadLink(input: { + readonly prompt: string; + readonly repository?: PromptLinkRepository | null; + readonly provider?: string | null; + readonly model?: string | null; + readonly origin?: string; +}): string { + const url = new URL("/new", input.origin ?? DEFAULT_HOSTED_APP_URL); + url.searchParams.set("prompt", input.prompt); + if (input.repository) { + url.searchParams.set("repo", `${input.repository.owner}/${input.repository.name}`); + } + if (input.provider) url.searchParams.set("provider", input.provider); + if (input.provider && input.model) url.searchParams.set("model", input.model); + return url.toString(); +} + +const APP_SCHEME = /^t3code(?:-[a-z]+)?:$/; +const THREAD_PATH = /^\/([^/]+)\/([^/]+)\/?$/; + +/** + * The in-app path a prompt link or thread deep link opens, or null for any other URL. + * Desktop uses it for links the OS hands over, both `t3code://` ones and, as the default + * browser on macOS, hosted-app ones. + */ +export function promptLinkAppPath( + value: string, + hostedAppUrl: string = DEFAULT_HOSTED_APP_URL, +): string | null { + if (!URL.canParse(value)) return null; + const url = new URL(value); + if (APP_SCHEME.test(url.protocol)) { + if (url.host === "new" && (url.pathname === "" || url.pathname === "/")) { + return `/new${url.search}`; + } + const thread = url.host === "threads" ? THREAD_PATH.exec(url.pathname) : null; + return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null; + } + if (!URL.canParse(hostedAppUrl) || url.origin !== new URL(hostedAppUrl).origin) return null; + if (url.pathname === "/new") return `/new${url.search}`; + // Hosted thread links only open in the app when they carry a prompt. A plain one is just a + // page on the hosted app. + const thread = url.searchParams.has("prompt") ? THREAD_PATH.exec(url.pathname) : null; + return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null; +} + +/** The `t3code://` link that hands an in-app path to an installed desktop or mobile app. */ +export function promptLinkDeepLink(appPath: string, scheme = "t3code"): string { + const url = new URL(appPath, "https://app.invalid"); + const host = url.pathname === "/new" ? "new" : `threads${url.pathname}`; + return `${scheme}://${host}${url.search}`; +} -- 2.54.0