| 1 | function dataFromNotificationResponse(response: unknown): Record<string, unknown> | null { |
| 2 | if (typeof response !== "object" || response === null) { |
| 3 | return null; |
| 4 | } |
| 5 | const notification = (response as { readonly notification?: unknown }).notification; |
| 6 | if (typeof notification !== "object" || notification === null) { |
| 7 | return null; |
| 8 | } |
| 9 | const request = (notification as { readonly request?: unknown }).request; |
| 10 | if (typeof request !== "object" || request === null) { |
| 11 | return null; |
| 12 | } |
| 13 | const content = (request as { readonly content?: unknown }).content; |
| 14 | if (typeof content !== "object" || content === null) { |
| 15 | return null; |
| 16 | } |
| 17 | const data = (content as { readonly data?: unknown }).data; |
| 18 | return typeof data === "object" && data !== null ? (data as Record<string, unknown>) : null; |
| 19 | } |
| 20 | |
| 21 | function identifierFromNotificationResponse(response: unknown): string | null { |
| 22 | if (typeof response !== "object" || response === null) { |
| 23 | return null; |
| 24 | } |
| 25 | const notification = (response as { readonly notification?: unknown }).notification; |
| 26 | if (typeof notification !== "object" || notification === null) { |
| 27 | return null; |
| 28 | } |
| 29 | const request = (notification as { readonly request?: unknown }).request; |
| 30 | if (typeof request !== "object" || request === null) { |
| 31 | return null; |
| 32 | } |
| 33 | const identifier = (request as { readonly identifier?: unknown }).identifier; |
| 34 | return typeof identifier === "string" ? identifier : null; |
| 35 | } |
| 36 | |
| 37 | function encodeThreadDeepLink(input: { |
| 38 | readonly environmentId: string; |
| 39 | readonly threadId: string; |
| 40 | }): string | null { |
| 41 | if (input.environmentId.length === 0 || input.threadId.length === 0) { |
| 42 | return null; |
| 43 | } |
| 44 | return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; |
| 45 | } |
| 46 | |
| 47 | function normalizeThreadDeepLink(value: string): string | null { |
| 48 | if ( |
| 49 | value.trim() !== value || |
| 50 | value.startsWith("//") || |
| 51 | value.includes("?") || |
| 52 | value.includes("#") |
| 53 | ) { |
| 54 | return null; |
| 55 | } |
| 56 | |
| 57 | const parts = value.split("/"); |
| 58 | if (parts.length !== 4 || parts[0] !== "" || parts[1] !== "threads") { |
| 59 | return null; |
| 60 | } |
| 61 | |
| 62 | try { |
| 63 | return encodeThreadDeepLink({ |
| 64 | environmentId: decodeURIComponent(parts[2] ?? ""), |
| 65 | threadId: decodeURIComponent(parts[3] ?? ""), |
| 66 | }); |
| 67 | } catch { |
| 68 | return null; |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * The deep link a notification would have to carry to target the thread the |
| 74 | * given route is showing, including its nested screens such as the file |
| 75 | * inspector. Null when the route is not inside a thread. |
| 76 | */ |
| 77 | export function threadDeepLinkOnScreen(pathname: string): string | null { |
| 78 | const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname); |
| 79 | if (!match?.[1] || !match[2]) { |
| 80 | return null; |
| 81 | } |
| 82 | return `/threads/${match[1]}/${match[2]}`; |
| 83 | } |
| 84 | |
| 85 | export function extractAgentNotificationDeepLink(response: unknown): string | null { |
| 86 | const data = dataFromNotificationResponse(response); |
| 87 | const deepLink = data?.deepLink; |
| 88 | if (typeof deepLink === "string") { |
| 89 | const normalizedDeepLink = normalizeThreadDeepLink(deepLink); |
| 90 | if (normalizedDeepLink) { |
| 91 | return normalizedDeepLink; |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | const environmentId = data?.environmentId; |
| 96 | const threadId = data?.threadId; |
| 97 | if (typeof environmentId === "string" && typeof threadId === "string") { |
| 98 | return encodeThreadDeepLink({ environmentId, threadId }); |
| 99 | } |
| 100 | return null; |
| 101 | } |
| 102 | |
| 103 | export function routeAgentNotificationResponseOnce(input: { |
| 104 | readonly handledResponseIds: Set<string>; |
| 105 | readonly response: unknown; |
| 106 | readonly navigate: (deepLink: string) => void; |
| 107 | }): void { |
| 108 | const responseId = identifierFromNotificationResponse(input.response); |
| 109 | if (responseId && input.handledResponseIds.has(responseId)) { |
| 110 | return; |
| 111 | } |
| 112 | if (responseId) { |
| 113 | input.handledResponseIds.add(responseId); |
| 114 | } |
| 115 | const deepLink = extractAgentNotificationDeepLink(input.response); |
| 116 | if (deepLink) { |
| 117 | input.navigate(deepLink); |
| 118 | } |
| 119 | } |