1import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2import {
3 AuthPreviewOperateScope,
4 MERGEBOT_BROWSER_THREAD_ID,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
7import {
8 isAtomCommandInterrupted,
9 squashAtomCommandFailure,
10} from "@t3tools/client-runtime/state/runtime";
11
12import { usePreviewAvailable } from "~/browser/previewRuntime";
13import { readThreadPreviewState, useThreadPreviewState } from "~/previewStateStore";
14import { useEnvironmentQuery } from "~/state/query";
15import { useThreadShell } from "~/state/entities";
16import { previewEnvironment } from "~/state/preview";
17import { useEnvironmentScope } from "~/state/session";
18import { useAtomCommand } from "~/state/use-atom-command";
19import { Button } from "../ui/button";
20import { PreviewPanel } from "../preview/PreviewPanel";
21import { closePreviewSession } from "../preview/closePreviewSession";
22import { openPreviewSession } from "../preview/openPreviewSession";
23import { usePreviewSession } from "../preview/usePreviewSession";
24
25export function MergebotBrowserPanel({
26 threadRef,
27 url,
28 onReady,
29}: {
30 threadRef: ScopedThreadRef;
31 url: string;
32 onReady?: ((tabId: string) => boolean) | undefined;
33}) {
34 usePreviewSession(threadRef);
35 const preview = useThreadPreviewState(threadRef);
36 const list = useEnvironmentQuery(
37 previewEnvironment.list({
38 environmentId: threadRef.environmentId,
39 input: { threadId: threadRef.threadId },
40 }),
41 );
42 const thread = useThreadShell(threadRef);
43 const ownerKnown =
44 thread !== null ||
45 threadRef.threadId === MERGEBOT_BROWSER_THREAD_ID ||
46 threadRef.threadId.startsWith(`${MERGEBOT_BROWSER_THREAD_ID}:`);
47 const available = usePreviewAvailable(threadRef.environmentId);
48 const canOperate = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope);
49 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
50 const closePreview = useAtomCommand(previewEnvironment.close);
51 const [tabId, setTabId] = useState<string | null>(null);
52 const [pending, setPending] = useState(false);
53 const [error, setError] = useState<string | null>(null);
54 const attempted = useRef(false);
55 const busy = useRef(false);
56 const handoffs = useRef(new Map<string, boolean>());
57 const matchingTab = Object.values(preview.sessions).find(
58 (session) => session.navStatus._tag !== "Idle" && session.navStatus.url === url,
59 );
60 const activeTabId = tabId && preview.sessions[tabId] ? tabId : (matchingTab?.tabId ?? null);
61 const isChatHandoff = onReady !== undefined && thread !== null;
62
63 const handOff = useCallback(
64 (openedTabId: string) => {
65 const previous = handoffs.current.get(openedTabId);
66 if (previous !== undefined) return previous;
67 const accepted = onReady?.(openedTabId) ?? true;
68 handoffs.current.set(openedTabId, accepted);
69 return accepted;
70 },
71 [onReady],
72 );
73
74 const open = useCallback(async () => {
75 if (!available || !canOperate || busy.current) return;
76 busy.current = true;
77 setPending(true);
78 setError(null);
79 try {
80 const matching = Object.values(readThreadPreviewState(threadRef).sessions).find(
81 (session) => session.navStatus._tag !== "Idle" && session.navStatus.url === url,
82 );
83 if (matching) {
84 handOff(matching.tabId);
85 setTabId(matching.tabId);
86 return;
87 }
88 const result = await openPreviewSession({ threadRef, url, openPreview });
89 if (isAtomCommandInterrupted(result)) return;
90 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
91 if (!handOff(result.value.tabId)) {
92 await closePreviewSession({
93 closePreview,
94 snapshot: result.value,
95 tabId: result.value.tabId,
96 threadRef,
97 });
98 return;
99 }
100 setTabId(result.value.tabId);
101 } catch (cause) {
102 setError(cause instanceof Error ? cause.message : "Try opening Mergebot again.");
103 } finally {
104 busy.current = false;
105 setPending(false);
106 }
107 }, [available, canOperate, closePreview, handOff, openPreview, threadRef, url]);
108
109 useLayoutEffect(() => {
110 if (activeTabId) handOff(activeTabId);
111 }, [activeTabId, handOff]);
112
113 useEffect(() => {
114 if (
115 !ownerKnown ||
116 !preview.listLoaded ||
117 activeTabId ||
118 attempted.current ||
119 !available ||
120 !canOperate
121 )
122 return;
123 attempted.current = true;
124 if (
125 thread?.settledOverride === "settled" ||
126 thread?.archivedAt != null ||
127 (!isChatHandoff && preview.recentlySeenUrls.includes(url))
128 )
129 return;
130 void open();
131 }, [
132 activeTabId,
133 available,
134 canOperate,
135 isChatHandoff,
136 open,
137 ownerKnown,
138 preview.listLoaded,
139 preview.recentlySeenUrls,
140 thread?.archivedAt,
141 thread?.settledOverride,
142 url,
143 ]);
144
145 if (activeTabId && available && canOperate) {
146 return <PreviewPanel mode="embedded" threadRef={threadRef} tabId={activeTabId} visible />;
147 }
148 const displayedError = error ?? (!preview.listLoaded ? list.error : null);
149 return (
150 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
151 <p
152 className="max-w-sm text-sm text-muted-foreground"
153 role={displayedError ? "alert" : "status"}
154 >
155 {displayedError ??
156 (!canOperate
157 ? "Pair this client with browser access to open Mergebot."
158 : !available
159 ? "Connect to an environment with browser support to open Mergebot."
160 : pending || !preview.listLoaded
161 ? "Opening Mergebot…"
162 : "Mergebot is closed.")}
163 </p>
164 {canOperate && available && (preview.listLoaded || displayedError) ? (
165 <Button
166 variant="outline"
167 size="sm"
168 disabled={pending}
169 aria-busy={pending}
170 onClick={() => (preview.listLoaded ? void open() : list.refresh())}
171 >
172 {displayedError ? "Retry" : "Open Mergebot"}
173 </Button>
174 ) : null}
175 </div>
176 );
177}