1function 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
21function 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
37function 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 try {
45 return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`;
46 } catch {
47 return null;
48 }
49}
50
51function normalizeThreadDeepLink(value: string): string | null {
52 if (
53 value.trim() !== value ||
54 value.startsWith("//") ||
55 value.includes("?") ||
56 value.includes("#")
57 ) {
58 return null;
59 }
60
61 const parts = value.split("/");
62 if (parts.length !== 4 || parts[0] !== "" || parts[1] !== "threads") {
63 return null;
64 }
65
66 try {
67 return encodeThreadDeepLink({
68 environmentId: decodeURIComponent(parts[2] ?? ""),
69 threadId: decodeURIComponent(parts[3] ?? ""),
70 });
71 } catch {
72 return null;
73 }
74}
75
76export function threadDeepLinkOnScreen(pathname: string): string | null {
77 const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname);
78 if (!match?.[1] || !match[2]) {
79 return null;
80 }
81 return `/threads/${match[1]}/${match[2]}`;
82}
83
84export function extractAgentNotificationDeepLink(response: unknown): string | null {
85 const data = dataFromNotificationResponse(response);
86 const deepLink = data?.deepLink;
87 if (typeof deepLink === "string") {
88 const normalizedDeepLink = normalizeThreadDeepLink(deepLink);
89 if (normalizedDeepLink) {
90 return normalizedDeepLink;
91 }
92 }
93
94 const environmentId = data?.environmentId;
95 const threadId = data?.threadId;
96 if (typeof environmentId === "string" && typeof threadId === "string") {
97 return encodeThreadDeepLink({ environmentId, threadId });
98 }
99 return null;
100}
101
102export function routeAgentNotificationResponseOnce(input: {
103 readonly handledResponseIds: Set<string>;
104 readonly response: unknown;
105 readonly navigate: (deepLink: string) => void;
106}): void {
107 const responseId = identifierFromNotificationResponse(input.response);
108 if (responseId && input.handledResponseIds.has(responseId)) {
109 return;
110 }
111 const deepLink = extractAgentNotificationDeepLink(input.response);
112 if (deepLink) {
113 input.navigate(deepLink);
114 }
115 if (responseId) {
116 input.handledResponseIds.add(responseId);
117 }
118}