(null);
+ const sessionRef = useRef<{ tabId: string; paneId: string } | null>(null);
+
+ const createOrAttach = electronTrpc.terminal.createOrAttach.useMutation({
+ onSuccess: () => setExpoState("running"),
+ onError: (error) => {
+ toast.error(`Failed to start Expo build: ${error.message}`);
+ setExpoState("idle");
+ sessionRef.current = null;
+ setActivePaneId(null);
+ },
+ });
+
+ const writeMutation = electronTrpc.terminal.write.useMutation({
+ onError: (error) => {
+ toast.error(`Terminal write failed: ${error.message}`);
+ },
+ });
+
+ const { data, isLoading } = electronTrpc.workspaces.detectExpo.useQuery({
+ worktreePath,
+ });
+
+ // Listen for terminal process exit to reset button state
+ electronTrpc.terminal.stream.useSubscription(activePaneId ?? "", {
+ enabled: !!activePaneId,
+ onData: (event) => {
+ if (event.type === "exit") {
+ setExpoState("idle");
+ }
+ },
+ });
+
+ // Reset state if the tracked tab is closed by the user
+ useEffect(() => {
+ if (!sessionRef.current) return;
+ const tabStillExists = tabs.some((t) => t.id === sessionRef.current?.tabId);
+ if (!tabStillExists) {
+ setExpoState("idle");
+ sessionRef.current = null;
+ setActivePaneId(null);
+ }
+ }, [tabs]);
+
+ const handleStart = useCallback(() => {
+ if (createOrAttach.isPending || writeMutation.isPending) return;
+
+ const session = sessionRef.current;
+
+ if (session) {
+ setActiveTab(workspaceId, session.tabId);
+ setExpoState("starting");
+ // \x03 = Ctrl+C (kill any running process), \x15 = Ctrl+U (clear partial input)
+ writeMutation.mutate(
+ { paneId: session.paneId, data: `\x03\x15${EXPO_COMMAND}\n` },
+ { onSuccess: () => setExpoState("running") },
+ );
+ } else {
+ setExpoState("starting");
+ const { tabId, paneId } = addTab(workspaceId);
+ sessionRef.current = { tabId, paneId };
+ setActivePaneId(paneId);
+ createOrAttach.mutate({
+ paneId,
+ tabId,
+ workspaceId,
+ initialCommands: [EXPO_COMMAND],
+ });
+ renameTab(tabId, "Expo iOS");
+ }
+ }, [workspaceId, addTab, renameTab, setActiveTab, createOrAttach, writeMutation]);
+
+ const handleStop = useCallback(() => {
+ const session = sessionRef.current;
+ if (!session || writeMutation.isPending) return;
+
+ // \x03 = Ctrl+C — terminal driver sends SIGINT to the foreground process group
+ writeMutation.mutate(
+ { paneId: session.paneId, data: "\x03" },
+ { onSuccess: () => setExpoState("idle") },
+ );
+ }, [writeMutation]);
+
+ const handleClick = useCallback(() => {
+ if (expoState === "running" && isHovered) {
+ handleStop();
+ } else if (expoState === "idle") {
+ handleStart();
+ }
+ }, [expoState, isHovered, handleStart, handleStop]);
+
+ // Hide button if loading or no Expo detected
+ if (isLoading || !data?.hasExpo) {
+ return null;
+ }
+
+ const isDisabled = expoState === "starting" || writeMutation.isPending;
+ const showStop = expoState === "running" && isHovered;
+
+ let tooltipText: string;
+ if (isDisabled) {
+ tooltipText =
+ expoState === "starting" ? "Starting Expo..." : "Stopping Expo...";
+ } else if (showStop) {
+ tooltipText = "Stop Expo build";
+ } else if (expoState === "running") {
+ tooltipText = "Expo build running";
+ } else {
+ tooltipText = "Run on iOS Device";
+ }
+
+ return (
+
+
+
+
+
+ {tooltipText}
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx
index 9e36080cd8c..c4a513e88b3 100644
--- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx
@@ -1,5 +1,6 @@
import { useParams } from "@tanstack/react-router";
import { electronTrpc } from "renderer/lib/electron-trpc";
+import { ExpoButton } from "./ExpoButton";
import { OpenInMenuButton } from "./OpenInMenuButton";
import { OrganizationDropdown } from "./OrganizationDropdown";
import { WindowControls } from "./WindowControls";
@@ -26,6 +27,12 @@ export function TopBar() {
+ {workspace?.worktreePath && workspace?.id && (
+
+ )}
{workspace?.worktreePath && (