Skip to content

feat(desktop): add Expo build button to TopBar#1017

Closed
danielz1z wants to merge 1 commit intosuperset-sh:mainfrom
danielz1z:feat/expo-button
Closed

feat(desktop): add Expo build button to TopBar#1017
danielz1z wants to merge 1 commit intosuperset-sh:mainfrom
danielz1z:feat/expo-button

Conversation

@danielz1z
Copy link
Copy Markdown

@danielz1z danielz1z commented Jan 28, 2026

Summary

  • Adds an Expo button to the TopBar that runs npx expo run:ios --device in a dedicated terminal tab
  • Button auto-detects Expo projects via package.json and hides when not applicable
  • Uses the Expo chevron logo with color-coded states: idle (muted), running (green), stop on hover (red)
  • Subscribes to the terminal stream to auto-reset on process exit (crash, shell death, tab close)

Details

Detection: New detectExpo tRPC procedure reads package.json for an expo dependency.

Session management: First click creates a terminal tab. Subsequent clicks reuse it, sending Ctrl+C before re-running the command.

Known limitation: The button can't detect when the Expo child process exits while the shell stays alive (e.g. user Ctrl+C's in the terminal directly). This requires OSC 133 shell integration which is out of scope — documented in EXPO_BUTTON.md.

Files changed

  • apps/desktop/docs/EXPO_BUTTON.md — Feature documentation
  • apps/desktop/src/lib/trpc/routers/workspaces/procedures/detect-expo.ts — New tRPC procedure
  • apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts — Wire up procedure
  • apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx — Button component
  • apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx — Mount in TopBar

Summary by CodeRabbit

  • New Features
    • Added Expo Button to the TopBar that automatically appears when an Expo project is detected.
    • One-click launch of Expo iOS with integrated terminal session management.
    • Button displays state indicators (Idle, Starting, Running) with contextual tooltips.
    • Stop running Expo processes directly from the button.
    • Automatic session cleanup when terminal tabs are closed.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 28, 2026

📝 Walkthrough

Walkthrough

This PR introduces Expo project detection and an ExpoButton UI component to the TopBar. It adds a new TRPC procedure to detect Expo in package.json, integrates it into the workspaces router, creates a React component managing terminal sessions for running Expo iOS, and updates TopBar to conditionally render the button.

Changes

Cohort / File(s) Summary
Documentation
apps/desktop/docs/EXPO_BUTTON.md
New documentation describing ExpoButton feature, states, terminal session management, exit detection, and PTY limitations with OSC 133 reference.
Backend TRPC Procedures
apps/desktop/src/lib/trpc/routers/workspaces/procedures/detect-expo.ts
New detect-expo procedure factory that reads package.json from worktreePath, parses JSON, and checks for expo in dependencies/devDependencies, returning { hasExpo }.
Router Integration
apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Imports and merges createDetectExpoProcedures into the workspaces router; updates documentation comment.
Frontend UI Components
apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx, apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx
New ExpoButton component with state management (idle/starting/running), tooltip logic, terminal integration via tRPC mutations/queries/subscriptions, and tab lifecycle management; TopBar updated to conditionally render ExpoButton alongside other workspace buttons.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant TopBar as TopBar UI
    participant ExpoButton as ExpoButton Component
    participant TRPC as TRPC Router
    participant FS as File System
    participant Terminal as Terminal Session
    
    User->>TopBar: Mount TopBar with workspace
    TopBar->>ExpoButton: Render with workspaceId, worktreePath
    ExpoButton->>TRPC: detectExpo(worktreePath)
    TRPC->>FS: readFile(package.json)
    FS-->>TRPC: JSON content
    TRPC-->>ExpoButton: { hasExpo: true }
    ExpoButton-->>User: Display button (Idle state)
    
    User->>ExpoButton: Click button
    ExpoButton->>TRPC: createOrAttach(paneId)
    TRPC-->>ExpoButton: paneId, tabId
    ExpoButton->>ExpoButton: Update state to "starting"
    ExpoButton->>TRPC: write(command: "expo start")
    TRPC->>Terminal: Send command
    Terminal-->>User: Display Expo output
    ExpoButton-->>User: Update button (Running state)
    
    ExpoButton->>TRPC: stream(paneId) subscribe
    Terminal->>TRPC: PTY exit event
    TRPC-->>ExpoButton: Exit notification
    ExpoButton->>ExpoButton: Reset state to "idle"
    ExpoButton-->>User: Update button (Idle state)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • refactor(desktop): extract db helpers from workspaces router #681: Both PRs modify workspaces router composition to merge modular procedure routers; this PR adds detect-expo procedures while #681 restructures the router pattern.
  • New desktop UI #102: This PR builds on TRPC scaffolding and TopBar UI foundations added in that PR by introducing Expo-specific detection and button component.
  • xterm ui #115: Both PRs integrate with terminal TRPC procedures (createOrAttach, write, stream) and terminal tab management—this PR adds ExpoButton consumer while #115 implements the underlying terminal router.

Poem

🐰 A button hops into the bar,
Detecting Expo near and far!
Click to start, streams align,
Terminal tabs in perfect line.
From idle to running with a bound,
Expo workflows come around! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(desktop): add Expo build button to TopBar' accurately summarizes the main feature being added—an Expo button component to the TopBar.
Description check ✅ Passed The PR description provides a comprehensive summary, details, and files changed, though it deviates from the template structure by using custom headings instead of the specified sections.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/workspaces/procedures/detect-expo.ts`:
- Around line 10-23: The try/catch in the detect-expo logic currently swallows
all errors; update the catch to log the error with context before returning the
fallback. Specifically, in the procedure that calls
secureFs.readFile(input.worktreePath, "package.json") and parses it to compute
hasExpo, change the catch to accept the error (e) and call console.error with a
prefixed message like "[workspaces/detect-expo] failed to read/parse
package.json at <input.worktreePath>:" followed by the error, then continue to
return { hasExpo: false } as the fallback.

In `@apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx`:
- Around line 90-97: The Expo start flow sets setExpoState("starting") but only
flips to "running" in writeMutation.mutate's onSuccess, so on write failure the
UI is stuck; add an onError callback to the writeMutation.mutate call used when
starting Expo (the block that calls setActiveTab(workspaceId, session.tabId),
setExpoState("starting"), writeMutation.mutate({... data:
`\x03\x15${EXPO_COMMAND}\n` ...})) that resets the state (e.g.,
setExpoState("idle")) on error; likewise update the stop flow in handleStop() to
include an onError that resets expo state so the button isn't permanently
disabled after a failed write.
- Around line 74-83: Add a new useEffect inside the ExpoButton component that
resets the session when the workspace context changes: if workspaceId or
worktreePath changes, call setExpoState("idle"), setActivePaneId(null), and set
sessionRef.current = null to avoid reusing stale pane/tab references from the
previous workspace (this prevents handleStart from writing to a stale paneId).
Place the effect alongside the existing effects in ExpoButton.tsx and depend on
[workspaceId, worktreePath] so it runs whenever the workspace changes.
🧹 Nitpick comments (1)
apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx (1)

1-7: Align component file layout with the component-per-folder convention.
This file would conform better to the repo’s component structure as TopBar/ExpoButton/ExpoButton.tsx with a barrel export.

As per coding guidelines: Use folder structure with one component per file: ComponentName/ComponentName.tsx with barrel export in index.ts.

Comment on lines +10 to +23
try {
const content = await secureFs.readFile(
input.worktreePath,
"package.json",
);
const packageJson = JSON.parse(content);
const hasExpo = !!(
packageJson.dependencies?.expo ||
packageJson.devDependencies?.expo
);
return { hasExpo };
} catch {
return { hasExpo: false };
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add contextual error logging instead of silent fallback.
Line 10-23 swallows file/JSON errors and returns false, which makes failures invisible and hard to diagnose. Please log with a prefixed message while keeping the fallback behavior.

🐛 Proposed fix
-			} catch {
-				return { hasExpo: false };
-			}
+			} catch (error) {
+				console.error(
+					"[workspaces/detectExpo] Failed to read or parse package.json",
+					error,
+				);
+				return { hasExpo: false };
+			}

As per coding guidelines: Never swallow errors silently; at minimum log them with context; Use prefixed console logging with pattern [domain/operation] message for all logging.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const content = await secureFs.readFile(
input.worktreePath,
"package.json",
);
const packageJson = JSON.parse(content);
const hasExpo = !!(
packageJson.dependencies?.expo ||
packageJson.devDependencies?.expo
);
return { hasExpo };
} catch {
return { hasExpo: false };
}
try {
const content = await secureFs.readFile(
input.worktreePath,
"package.json",
);
const packageJson = JSON.parse(content);
const hasExpo = !!(
packageJson.dependencies?.expo ||
packageJson.devDependencies?.expo
);
return { hasExpo };
} catch (error) {
console.error(
"[workspaces/detectExpo] Failed to read or parse package.json",
error,
);
return { hasExpo: false };
}
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/workspaces/procedures/detect-expo.ts`
around lines 10 - 23, The try/catch in the detect-expo logic currently swallows
all errors; update the catch to log the error with context before returning the
fallback. Specifically, in the procedure that calls
secureFs.readFile(input.worktreePath, "package.json") and parses it to compute
hasExpo, change the catch to accept the error (e) and call console.error with a
prefixed message like "[workspaces/detect-expo] failed to read/parse
package.json at <input.worktreePath>:" followed by the error, then continue to
return { hasExpo: false } as the fallback.

Comment on lines +74 to +83
// 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]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's verify the file exists and check its content around lines 74-83
cat -n apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx | head -100

Repository: superset-sh/superset

Length of output: 4235


🏁 Script executed:

# Continue reading the file to see the rest of the handleStart function and any other relevant code
cat -n apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx | tail -n +100 | head -50

Repository: superset-sh/superset

Length of output: 1887


🏁 Script executed:

# Search for other places where workspace changes might be handled or state is cleared
rg "workspaceId|worktreePath" apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx -B2 -A2

Repository: superset-sh/superset

Length of output: 1080


🏁 Script executed:

# Check if there's any zustand store managing workspace state or similar cleanup patterns
rg "useEffect.*workspaceId|useEffect.*workspace" apps/desktop/src/renderer -t tsx -A 3 | head -50

Repository: superset-sh/superset

Length of output: 91


🏁 Script executed:

# Check the tabs store to understand how tabs relate to workspaces
cat apps/desktop/src/renderer/stores/tabs/store.ts

Repository: superset-sh/superset

Length of output: 31456


🏁 Script executed:

# Check how handleStart uses workspaceId and if the cross-workspace issue is real
rg "setActiveTab" apps/desktop/src/renderer --type ts -B2 -A2 | head -60

Repository: superset-sh/superset

Length of output: 5773


🏁 Script executed:

# Verify the current state of the component to see if there are any other workspace-related resets
grep -n "workspaceId\|worktreePath" apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx

Repository: superset-sh/superset

Length of output: 424


🏁 Script executed:

# Check if there's any workspace change detection or cleanup elsewhere in the component
cat -n apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx | tail -60

Repository: superset-sh/superset

Length of output: 2325


🏁 Script executed:

# Verify the full list of dependencies in handleStart callback to see if workspaceId changes already trigger recreation
rg "const handleStart" apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx -A 30

Repository: superset-sh/superset

Length of output: 1057


Reset the session when the workspace changes to prevent cross‑workspace state reuse.

When a user switches workspaces, sessionRef and activePaneId retain references to the previous workspace's tab and pane. If the user clicks "Start" in the new workspace, handleStart will attempt to write to a stale paneId that may not exist or belong to a different workspace, causing terminal operations to fail unnecessarily.

Add a useEffect that clears the session state whenever workspaceId or worktreePath changes:

Proposed fix
 	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]);

+	useEffect(() => {
+		sessionRef.current = null;
+		setActivePaneId(null);
+		setExpoState("idle");
+	}, [workspaceId, worktreePath]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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]);
// 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]);
useEffect(() => {
sessionRef.current = null;
setActivePaneId(null);
setExpoState("idle");
}, [workspaceId, worktreePath]);
🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx`
around lines 74 - 83, Add a new useEffect inside the ExpoButton component that
resets the session when the workspace context changes: if workspaceId or
worktreePath changes, call setExpoState("idle"), setActivePaneId(null), and set
sessionRef.current = null to avoid reusing stale pane/tab references from the
previous workspace (this prevents handleStart from writing to a stale paneId).
Place the effect alongside the existing effects in ExpoButton.tsx and depend on
[workspaceId, worktreePath] so it runs whenever the workspace changes.

Comment on lines +90 to +97
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") },
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and read the specified lines
if [ -f "apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx" ]; then
  echo "=== File content at lines 85-105 ===" 
  sed -n '85,105p' "apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx"
else
  echo "File not found, searching for ExpoButton.tsx"
  find . -name "ExpoButton.tsx" -type f
fi

Repository: superset-sh/superset

Length of output: 782


🏁 Script executed:

# Check the full context of the component to understand state management
wc -l apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx

Repository: superset-sh/superset

Length of output: 139


🏁 Script executed:

# Search for writeMutation definition to understand how it's used
rg -A 10 -B 5 "writeMutation" apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx

Repository: superset-sh/superset

Length of output: 2422


Reset state on write failure to avoid a stuck "Starting" state.

The current code (line 90-97) sets expoState to "starting" but only updates it to "running" on success. While writeMutation has a global onError handler that shows a toast, this does NOT reset the state. On write failure, expoState remains "starting" and the button stays disabled, requiring a reload to recover. Add an onError callback to reset the state:

Proposed fix
 			writeMutation.mutate(
 				{ paneId: session.paneId, data: `\x03\x15${EXPO_COMMAND}\n` },
-				{ onSuccess: () => setExpoState("running") },
+				{
+					onSuccess: () => setExpoState("running"),
+					onError: () => setExpoState("idle"),
+				},
 			);

Note: handleStop() has the same issue and should also include error handling.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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") },
);
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"),
onError: () => setExpoState("idle"),
},
);
🤖 Prompt for AI Agents
In `@apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx`
around lines 90 - 97, The Expo start flow sets setExpoState("starting") but only
flips to "running" in writeMutation.mutate's onSuccess, so on write failure the
UI is stuck; add an onError callback to the writeMutation.mutate call used when
starting Expo (the block that calls setActiveTab(workspaceId, session.tabId),
setExpoState("starting"), writeMutation.mutate({... data:
`\x03\x15${EXPO_COMMAND}\n` ...})) that resets the state (e.g.,
setExpoState("idle")) on error; likewise update the stop flow in handleStop() to
include an onError that resets expo state so the button isn't permanently
disabled after a failed write.

@danielz1z danielz1z closed this by deleting the head repository Feb 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant