1import type { PromptLinkRepository } from "@t3tools/shared/promptLinks";
2
3/** The query keys a prompt link adds to the `new` and thread routes. */
4export const PROMPT_LINK_PARAM_KEYS = ["prompt", "repo", "pr", "provider", "model"] as const;
5
6type RouteParams = Readonly<Record<string, unknown>> | undefined;
7
8/**
9 * Rebuilds the link's query from React Navigation route params, so the shared parser reads the
10 * same input on every surface. Null when the route carries no prompt link.
11 */
12export function promptLinkSearchFromParams(
13 params: RouteParams,
14 keys: ReadonlyArray<string> = PROMPT_LINK_PARAM_KEYS,
15): string | null {
16 const search = new URLSearchParams();
17 for (const key of keys) {
18 const raw = params?.[key];
19 const value = Array.isArray(raw) ? raw[0] : raw;
20 if (typeof value === "string") search.set(key, value);
21 }
22 return search.size > 0 ? search.toString() : null;
23}
24
25/** Route params that remove a handled prompt link, so it applies once. */
26export function clearedPromptLinkParams(keys: ReadonlyArray<string> = PROMPT_LINK_PARAM_KEYS) {
27 return Object.fromEntries(keys.map((key) => [key, undefined]));
28}
29
30/**
31 * The text of the one notice a prompt link shows. Every link says the prompt came from a link;
32 * hidden characters replace that line with a stronger warning.
33 */
34export function promptLinkNotice(input: {
35 readonly hiddenCharacters: number;
36 readonly modelUnavailable?: boolean;
37 readonly pullRequestUnavailable?: number | null;
38}): string {
39 return [
40 input.hiddenCharacters > 0
41 ? "This prompt had hidden characters, it may be malicious. They were removed. Read it before you send."
42 : "This prompt came from a link. Read it before you send.",
43 input.modelUnavailable
44 ? "The requested model is not available, so your default is used."
45 : null,
46 input.pullRequestUnavailable != null
47 ? `Pull request #${input.pullRequestUnavailable} could not be loaded, so this task starts from your default branch.`
48 : null,
49 ]
50 .filter((line) => line !== null)
51 .join("\n\n");
52}
53
54/** How a linked prompt lands in an existing thread composer. */
55export function linkedThreadDraftAction(currentText: string, prompt: string) {
56 if (currentText.trim().length === 0) return "fill" as const;
57 return currentText === prompt ? ("keep" as const) : ("ask" as const);
58}
59
60/**
61 * The machine a prompt without a project opens on: the most recently active one that offers
62 * threads without a project, else the first that does.
63 */
64export function promptLinkScratchEnvironmentId<Id extends string>(
65 environmentIdsMostRecentFirst: ReadonlyArray<Id>,
66 scratchEnvironmentIds: ReadonlyArray<Id>,
67): Id | null {
68 return (
69 environmentIdsMostRecentFirst.find((id) => scratchEnvironmentIds.includes(id)) ??
70 scratchEnvironmentIds[0] ??
71 null
72 );
73}
74
75/** Each environment once, by its most recently updated thread first. */
76export function latestThreadEnvironmentIds<Id extends string>(
77 threads: ReadonlyArray<{ readonly environmentId: Id; readonly updatedAt: string }>,
78): ReadonlyArray<Id> {
79 const latest = new Map<Id, string>();
80 for (const thread of threads) {
81 if (thread.updatedAt > (latest.get(thread.environmentId) ?? "")) {
82 latest.set(thread.environmentId, thread.updatedAt);
83 }
84 }
85 return Array.from(latest.keys()).sort((left, right) =>
86 (latest.get(right) ?? "").localeCompare(latest.get(left) ?? ""),
87 );
88}
89
90/** `owner/name`, as the user knows the repository. */
91export const promptLinkRepositoryName = (repository: PromptLinkRepository) =>
92 `${repository.owner}/${repository.name}`;