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 return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`;
45}
46
47function 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 */
77export 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
85export 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
103export 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}