| 1 | import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 2 | import { |
| 3 | AuthPreviewOperateScope, |
| 4 | MERGEBOT_BROWSER_THREAD_ID, |
| 5 | type ScopedThreadRef, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import { |
| 8 | isAtomCommandInterrupted, |
| 9 | squashAtomCommandFailure, |
| 10 | } from "@t3tools/client-runtime/state/runtime"; |
| 11 | |
| 12 | import { usePreviewAvailable } from "~/browser/previewRuntime"; |
| 13 | import { readThreadPreviewState, useThreadPreviewState } from "~/previewStateStore"; |
| 14 | import { useEnvironmentQuery } from "~/state/query"; |
| 15 | import { useThreadShell } from "~/state/entities"; |
| 16 | import { previewEnvironment } from "~/state/preview"; |
| 17 | import { useEnvironmentScope } from "~/state/session"; |
| 18 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 19 | import { Button } from "../ui/button"; |
| 20 | import { PreviewPanel } from "../preview/PreviewPanel"; |
| 21 | import { closePreviewSession } from "../preview/closePreviewSession"; |
| 22 | import { openPreviewSession } from "../preview/openPreviewSession"; |
| 23 | import { usePreviewSession } from "../preview/usePreviewSession"; |
| 24 | |
| 25 | export 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 | } |