1import { useAtomValue } from "@effect/atom-react";
2import {
3 AuthPreviewOperateScope,
4 type DevServerStatus,
5 type DevServerTarget,
6 type ScopedThreadRef,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import { GlobeIcon } from "lucide-react";
10import { useEffect, useEffectEvent, useRef, useState } from "react";
11
12import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
13import { previewRuntimeFor } from "~/browser/previewRuntime";
14import { useRightPanelStore } from "~/rightPanelStore";
15import { devServerEnvironment } from "~/state/devServer";
16import { useEnvironment } from "~/state/environments";
17import { previewEnvironment } from "~/state/preview";
18import { serverEnvironment } from "~/state/server";
19import { useAtomCommand } from "~/state/use-atom-command";
20import { useEnvironmentScope } from "~/state/session";
21import { openPreviewSession } from "../preview/openPreviewSession";
22import { Button } from "../ui/button";
23import { stackedThreadToast, toastManager } from "../ui/toast";
24
25interface Props {
26 threadRef: ScopedThreadRef;
27 target: DevServerTarget;
28}
29
30export function ComposerDevServerStatus(props: Props) {
31 const config = useAtomValue(serverEnvironment.configValueAtom(props.threadRef.environmentId));
32 const canRead = useAtomValue(
33 devServerEnvironment.status.permissionAtom(props.threadRef.environmentId),
34 );
35 if (!config?.devServerStatus || !canRead) return null;
36 return <DevServerButton key={JSON.stringify([props.threadRef, props.target])} {...props} />;
37}
38
39function DevServerButton({ threadRef, target }: Props) {
40 const connected = useEnvironment(threadRef.environmentId)?.connection.phase === "connected";
41 const [status, setStatus] = useState<DevServerStatus | null>(null);
42 const [pending, setPending] = useState(false);
43 const [unavailable, setUnavailable] = useState(false);
44 const live = useRef(true);
45 const busy = useRef(false);
46 const load = useAtomCommand(devServerEnvironment.status, { reportFailure: false });
47 const start = useAtomCommand(devServerEnvironment.start, { reportFailure: false });
48 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
49 const canPreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope);
50 const canStart = useAtomValue(devServerEnvironment.start.permissionAtom(threadRef.environmentId));
51 const poll = useEffectEvent(async () => {
52 if (busy.current || document.visibilityState === "hidden") return true;
53 const result = await load({ environmentId: threadRef.environmentId, input: target });
54 if (!live.current || busy.current) return true;
55 if (result._tag === "Failure") {
56 setStatus(null);
57 setUnavailable(true);
58 return false;
59 }
60 setStatus(result.value);
61 setUnavailable(false);
62 return true;
63 });
64
65 useEffect(() => {
66 live.current = true;
67 let active = true;
68 let timer: ReturnType<typeof setTimeout> | undefined;
69 const refresh = async () => {
70 if (!active) return;
71 if ((await poll()) && active) timer = setTimeout(() => void refresh(), 5000);
72 };
73 if (connected && !unavailable) void refresh();
74 return () => {
75 active = false;
76 live.current = false;
77 clearTimeout(timer);
78 };
79 }, [connected, unavailable]);
80
81 const open = async () => {
82 if (!connected || busy.current) return;
83 busy.current = true;
84 setPending(true);
85 try {
86 let result = await load({ environmentId: threadRef.environmentId, input: target });
87 if (!live.current) return;
88 if (result._tag === "Failure") throw result.cause;
89 if (!result.value.frontendUrl && result.value.canStart) {
90 result = await start({ environmentId: threadRef.environmentId, input: target });
91 if (!live.current) return;
92 if (result._tag === "Failure") throw result.cause;
93 }
94 setStatus(result.value);
95 setUnavailable(false);
96 const frontendUrl = result.value.frontendUrl;
97 if (!frontendUrl)
98 throw new Error("Frontend unavailable. Check the dev server and try again.");
99 const url =
100 previewRuntimeFor(threadRef.environmentId) === "server"
101 ? frontendUrl
102 : resolveDiscoveredServerUrl(threadRef.environmentId, frontendUrl);
103 const opened = await openPreviewSession({ threadRef, url, openPreview });
104 if (!live.current) return;
105 if (opened._tag === "Failure") throw opened.cause;
106 useRightPanelStore.getState().openBrowser(threadRef, opened.value.tabId);
107 } catch (cause) {
108 if (!live.current) return;
109 const error = Cause.isCause(cause) ? Cause.squash(cause) : cause;
110 setUnavailable(true);
111 toastManager.add(
112 stackedThreadToast({
113 type: "error",
114 title: "Could not open frontend",
115 description:
116 error instanceof Error ? error.message : "Check the dev server and try again.",
117 }),
118 );
119 } finally {
120 busy.current = false;
121 if (live.current) setPending(false);
122 }
123 };
124
125 if (!status && !unavailable) return null;
126 if (status && !status.canStart && status.services.length === 0) return null;
127 const ready = status?.services.filter((service) => service.status === "ready").length ?? 0;
128 const total = status?.services.length ?? 0;
129 const label = pending
130 ? "Opening…"
131 : unavailable
132 ? "Servers unavailable"
133 : status?.stopping
134 ? "Stopping…"
135 : total > 0
136 ? `Servers ${ready}/${total}`
137 : "Start frontend";
138 const canOpen = status?.frontendUrl || (status?.canStart && canStart) || unavailable;
139 const detail = status?.services.map((service) => `${service.id}: ${service.status}`).join(", ");
140
141 return (
142 <Button
143 variant="ghost-muted"
144 size="compact"
145 disabled={!connected || pending || status?.stopping || !canOpen || !canPreview}
146 aria-label={label}
147 aria-busy={pending}
148 title={detail || undefined}
149 onPointerDown={(event) => event.preventDefault()}
150 onClick={() => void open()}
151 >
152 <GlobeIcon aria-hidden="true" />
153 <span role="status">{label}</span>
154 </Button>
155 );
156}