Skip to content

Commit 56093be

Browse files
bidahclaude
andcommitted
feat(remote-control): add Pusher sync so desktop shows overlay and refreshes iframe when mobile edits
- Server triggers remote-control-start/complete events on sandbox-${sandboxId} channel - Desktop chat panel shows full-cover overlay while mobile agent is editing - iFrame remounts on complete to guarantee latest preview is shown - Mobile ChatScreen sends source: 'remote-control' to identify requests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0dc709b commit 56093be

10 files changed

Lines changed: 229 additions & 3 deletions

File tree

‎apps/web/app/(app)/api/chat/route.ts‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { canUserSendMessage, incrementMessageUsage } from '@/lib/message-usage'
55
import { corsHeaders, handleCorsOptions } from '@/lib/cors'
66
import { dispatchToAgent } from '@/lib/agent-dispatcher'
77
import type { AgentType } from '@/lib/agent-dispatcher'
8+
import { getPusherServer } from '@react-native-vibe-code/pusher/server'
89

910
type ClaudeCodeResponse = {
1011
type: 'message' | 'completion' | 'error'
@@ -30,6 +31,7 @@ export async function POST(req: Request) {
3031
imageAttachments,
3132
skills,
3233
agentType,
34+
source,
3335
}: {
3436
messages: UIMessage[]
3537
projectId: string
@@ -40,18 +42,23 @@ export async function POST(req: Request) {
4042
imageAttachments?: Array<{ url: string; contentType: string; name: string; size: number }>
4143
skills?: string[]
4244
agentType?: AgentType
45+
source?: string
4346
} = await req.json()
4447

48+
const isRemoteControl = source === 'remote-control'
49+
4550
// If userId not provided (mobile app), look it up from the project
4651
let userId = bodyUserId
47-
if (!userId && projectId) {
52+
let remoteSandboxId: string | null = null
53+
if ((!userId || isRemoteControl) && projectId) {
4854
const project = await db
49-
.select({ userId: projects.userId })
55+
.select({ userId: projects.userId, sandboxId: projects.sandboxId })
5056
.from(projects)
5157
.where(eq(projects.id, projectId))
5258
.limit(1)
5359
if (project.length > 0) {
54-
userId = project[0].userId
60+
if (!userId) userId = project[0].userId
61+
remoteSandboxId = project[0].sandboxId ?? null
5562
}
5663
}
5764

@@ -368,6 +375,20 @@ export async function POST(req: Request) {
368375
}
369376
}, 10000) // Check every 10 seconds
370377

378+
// Notify desktop that remote control is editing
379+
if (isRemoteControl && remoteSandboxId) {
380+
try {
381+
const pusher = getPusherServer()
382+
await pusher.trigger(
383+
`sandbox-${remoteSandboxId}`,
384+
'remote-control-start',
385+
{}
386+
)
387+
} catch (e) {
388+
console.error('[Chat Route] Failed to trigger remote-control-start:', e)
389+
}
390+
}
391+
371392
try {
372393
// Call the handler module directly - no HTTP, no timeout issues!
373394
await dispatchToAgent(
@@ -416,6 +437,20 @@ export async function POST(req: Request) {
416437
// Save to DB BEFORE closing the stream
417438
await saveMessagesToDatabase(fullContent, 'onComplete')
418439

440+
// Notify desktop that remote control is done
441+
if (isRemoteControl && remoteSandboxId) {
442+
try {
443+
const pusher = getPusherServer()
444+
await pusher.trigger(
445+
`sandbox-${remoteSandboxId}`,
446+
'remote-control-complete',
447+
{}
448+
)
449+
} catch (e) {
450+
console.error('[Chat Route] Failed to trigger remote-control-complete:', e)
451+
}
452+
}
453+
419454
// Use safe helpers for final message and close
420455
safeEnqueue({
421456
type: 'text-delta',
@@ -435,6 +470,20 @@ export async function POST(req: Request) {
435470
// Save to DB BEFORE closing the stream
436471
await saveMessagesToDatabase(fullContent, 'onError')
437472

473+
// Notify desktop that remote control is done (even on error)
474+
if (isRemoteControl && remoteSandboxId) {
475+
try {
476+
const pusher = getPusherServer()
477+
await pusher.trigger(
478+
`sandbox-${remoteSandboxId}`,
479+
'remote-control-complete',
480+
{}
481+
)
482+
} catch (e) {
483+
console.error('[Chat Route] Failed to trigger remote-control-complete on error:', e)
484+
}
485+
}
486+
438487
safeCloseStream('error', errorContent)
439488
},
440489
}

‎apps/web/app/(app)/p/[id]/project-page.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2472,6 +2472,7 @@ export function ProjectPageInternal({ opencodeEnabled = false, template: templat
24722472
isCloudPanelOpen={mobileSidebarPanel === 'cloud'}
24732473
onCloudPanelOpen={() => setMobileSidebarPanel('cloud')}
24742474
onCloudPanelClose={() => setMobileSidebarPanel(null)}
2475+
onIframeRefresh={() => setPreviewKey(prev => prev + 1)}
24752476
/>
24762477
{latestError && (
24772478
<ErrorNotificationCard
@@ -2582,6 +2583,7 @@ export function ProjectPageInternal({ opencodeEnabled = false, template: templat
25822583
isCloudPanelOpen={desktopSidebarPanel === 'cloud'}
25832584
onCloudPanelOpen={() => setDesktopSidebarPanel('cloud')}
25842585
onCloudPanelClose={() => setDesktopSidebarPanel(null)}
2586+
onIframeRefresh={() => setPreviewKey(prev => prev + 1)}
25852587
/>
25862588
{latestError && (
25872589
<ErrorNotificationCard

‎apps/web/components/chat-panel.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Messages } from '@/components/chat/messages'
66
import type { Message } from 'ai'
77
import type React from 'react'
88
import { usePusherHoverSelection } from '@/hooks/usePusherHoverSelection'
9+
import { useRemoteControlStatus } from '@/hooks/useRemoteControlStatus'
910

1011
interface ChatPanelProps {
1112
messages: Message[]
@@ -37,6 +38,7 @@ interface ChatPanelProps {
3738
isCloudPanelOpen?: boolean
3839
onCloudPanelOpen?: () => void
3940
onCloudPanelClose?: () => void
41+
onIframeRefresh?: () => void
4042
}
4143

4244
export function ChatPanel({
@@ -62,9 +64,16 @@ export function ChatPanel({
6264
isCloudPanelOpen = false,
6365
onCloudPanelOpen,
6466
onCloudPanelClose,
67+
onIframeRefresh,
6568
}: ChatPanelProps) {
69+
const { isRemoteControlActive } = useRemoteControlStatus({
70+
sandboxId: sandboxId ?? null,
71+
onComplete: onIframeRefresh,
72+
})
73+
6674
return (
6775
<BaseChatPanel
76+
isRemoteControlActive={isRemoteControlActive}
6877
messages={messages}
6978
input={input}
7079
handleInputChange={handleInputChange}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use client'
2+
3+
import PusherClient from 'pusher-js'
4+
import { useEffect, useState, useRef } from 'react'
5+
6+
export function useRemoteControlStatus({
7+
sandboxId,
8+
onComplete,
9+
}: {
10+
sandboxId: string | null
11+
onComplete?: () => void
12+
}) {
13+
const [isRemoteControlActive, setIsRemoteControlActive] = useState(false)
14+
const pusherRef = useRef<PusherClient | null>(null)
15+
const onCompleteRef = useRef(onComplete)
16+
onCompleteRef.current = onComplete
17+
18+
useEffect(() => {
19+
if (!sandboxId) return
20+
21+
const pusher = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_APP_KEY!, {
22+
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
23+
})
24+
pusherRef.current = pusher
25+
26+
const channel = pusher.subscribe(`sandbox-${sandboxId}`)
27+
28+
channel.bind('remote-control-start', () => {
29+
setIsRemoteControlActive(true)
30+
})
31+
32+
channel.bind('remote-control-complete', () => {
33+
setIsRemoteControlActive(false)
34+
onCompleteRef.current?.()
35+
})
36+
37+
return () => {
38+
channel.unbind_all()
39+
channel.unsubscribe()
40+
pusher.disconnect()
41+
pusherRef.current = null
42+
}
43+
}, [sandboxId])
44+
45+
return { isRemoteControlActive }
46+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Remote Control Pusher Integration Design
2+
3+
## Overview
4+
5+
When a mobile user triggers a chat via the Remote Control package, the desktop web chat panel should show a blocking overlay ("Remote Control is editing the app"), then refresh the iframe preview once the agent completes.
6+
7+
## Pusher Events
8+
9+
Add to `packages/visual-edits/src/types/index.ts` on the existing `sandbox-${sandboxId}` channel:
10+
11+
- `remote-control-start` — fired when server begins processing a mobile chat request
12+
- `remote-control-complete` — fired when server finishes streaming the response
13+
14+
## Server Changes
15+
16+
The chat API route (`apps/web/app/api/chat/route.ts`) detects mobile requests via `source: 'remote-control'` in the request body. At stream start it triggers `remote-control-start`; at stream end it triggers `remote-control-complete`.
17+
18+
## Web Hook
19+
20+
New `useRemoteControlStatus(sandboxId, onComplete)` hook in `packages/visual-edits/src/web/`:
21+
22+
- Subscribes to `sandbox-${sandboxId}` channel
23+
- `remote-control-start` → `isRemoteControlActive = true`
24+
- `remote-control-complete` → `isRemoteControlActive = false`, calls `onComplete`
25+
- `onComplete` increments `iframeKey` in `preview-panel.tsx` to remount the iframe
26+
27+
## Overlay Component
28+
29+
New `RemoteControlOverlay` in `packages/chat/src/components/`:
30+
31+
- Full-cover absolute overlay on the chat panel
32+
- Shows spinner + "Remote Control is editing the app"
33+
- No dismiss — only server clears it
34+
- `BaseChatPanel` gets new optional prop `isRemoteControlActive?: boolean`
35+
36+
## Data Flow
37+
38+
```
39+
Mobile chat request (source: 'remote-control')
40+
→ server triggers remote-control-start
41+
→ desktop overlay shown, chat blocked
42+
→ server finishes streaming
43+
→ server triggers remote-control-complete
44+
→ overlay removed, iframeKey++ → iframe remounts
45+
```
46+
47+
## Mobile Changes
48+
49+
`ChatScreen.tsx` sends `source: 'remote-control'` in the chat request body. No other mobile changes needed.

‎packages/chat/src/components/chat-panel.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export interface ChatPanelProps {
5353
isCloudPanelOpen?: boolean
5454
onCloudPanelOpen?: () => void
5555
onCloudPanelClose?: () => void
56+
/** Whether remote control (mobile) is currently editing */
57+
isRemoteControlActive?: boolean
5658
/** Render function for messages container */
5759
renderMessages: (props: {
5860
messages: Message[]
@@ -119,6 +121,7 @@ export function ChatPanel({
119121
isCloudPanelOpen = false,
120122
onCloudPanelOpen,
121123
onCloudPanelClose,
124+
isRemoteControlActive = false,
122125
renderMessages,
123126
renderInput,
124127
useHoverSelection,
@@ -191,6 +194,14 @@ export function ChatPanel({
191194
return (
192195
<div className="flex flex-col border-r relative overflow-hidden h-full flex-1 min-h-0">
193196

197+
{/* Remote Control overlay */}
198+
{isRemoteControlActive && (
199+
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/95 backdrop-blur-sm">
200+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-foreground border-t-transparent mb-4" />
201+
<p className="text-sm font-medium text-foreground">Remote Control is editing the app</p>
202+
</div>
203+
)}
204+
194205
{/* Messages Container with Conversation (use-stick-to-bottom) */}
195206
{renderMessages({
196207
messages,

‎packages/remote-control/src/ChatScreen.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export function ChatScreen({ onNavigateHome, onClose, selectedProject }: ChatScr
182182
const newBody = {
183183
projectId: requestData?.body?.projectId || selectedProject?.id || projectId,
184184
messages: lastUserMessage ? [lastUserMessage] : [],
185+
source: 'remote-control',
185186
}
186187

187188
console.log('[ChatScreen] Final request body:', newBody)

‎packages/visual-edits/src/types/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ export const PUSHER_EVENTS = {
162162
HOVER_MODE_TOGGLE: 'hover-mode-toggle',
163163
/** Event when element is selected in sandbox */
164164
HOVER_SELECTION: 'hover-selection',
165+
/** Event when remote control (mobile) starts editing */
166+
REMOTE_CONTROL_START: 'remote-control-start',
167+
/** Event when remote control (mobile) finishes editing */
168+
REMOTE_CONTROL_COMPLETE: 'remote-control-complete',
165169
} as const
166170

167171
/**

‎packages/visual-edits/src/web/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export { SelectionIndicator, getFileEditionRef } from './SelectionIndicator'
1212
export type { SelectionIndicatorExtendedProps } from './SelectionIndicator'
1313

1414
export { usePusherHoverSelection } from './usePusherHoverSelection'
15+
export { useRemoteControlStatus } from './useRemoteControlStatus'
16+
export type { UseRemoteControlStatusOptions, UseRemoteControlStatusResult } from './useRemoteControlStatus'
1517

1618
// Re-export types for convenience
1719
export type {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use client'
2+
3+
import PusherClient from 'pusher-js'
4+
import { useEffect, useState, useRef, useCallback } from 'react'
5+
import { PUSHER_EVENTS, getSandboxChannelName } from '../types'
6+
7+
export interface UseRemoteControlStatusOptions {
8+
sandboxId: string | null
9+
onComplete?: () => void
10+
}
11+
12+
export interface UseRemoteControlStatusResult {
13+
isRemoteControlActive: boolean
14+
}
15+
16+
export function useRemoteControlStatus({
17+
sandboxId,
18+
onComplete,
19+
}: UseRemoteControlStatusOptions): UseRemoteControlStatusResult {
20+
const [isRemoteControlActive, setIsRemoteControlActive] = useState(false)
21+
const pusherRef = useRef<PusherClient | null>(null)
22+
const onCompleteRef = useRef(onComplete)
23+
onCompleteRef.current = onComplete
24+
25+
useEffect(() => {
26+
if (!sandboxId) return
27+
28+
const pusher = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_APP_KEY!, {
29+
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
30+
})
31+
pusherRef.current = pusher
32+
33+
const channel = pusher.subscribe(getSandboxChannelName(sandboxId))
34+
35+
channel.bind(PUSHER_EVENTS.REMOTE_CONTROL_START, () => {
36+
setIsRemoteControlActive(true)
37+
})
38+
39+
channel.bind(PUSHER_EVENTS.REMOTE_CONTROL_COMPLETE, () => {
40+
setIsRemoteControlActive(false)
41+
onCompleteRef.current?.()
42+
})
43+
44+
return () => {
45+
channel.unbind_all()
46+
channel.unsubscribe()
47+
pusher.disconnect()
48+
pusherRef.current = null
49+
}
50+
}, [sandboxId])
51+
52+
return { isRemoteControlActive }
53+
}

0 commit comments

Comments
 (0)