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