diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 8e78ec36..671e2377 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -622,6 +622,10 @@ export class SyncEngine { await this.sessionRpc(sessionId, 'abort', { reason: 'User aborted via Telegram Bot' }) } + async switchSession(sessionId: string, to: 'remote' | 'local'): Promise { + await this.sessionRpc(sessionId, 'switch', { to }) + } + async setPermissionMode( sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index a08d1022..b03bda01 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -111,6 +111,21 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ ok: true }) }) + app.post('/sessions/:id/switch', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + await engine.switchSession(sessionResult.sessionId, 'remote') + return c.json({ ok: true }) + }) + app.post('/sessions/:id/permission-mode', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4689c927..eb2bb3ea 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -125,6 +125,13 @@ export class ApiClient { }) } + async switchSession(sessionId: string): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, { + method: 'POST', + body: JSON.stringify({}) + }) + } + async setPermissionMode(sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permission-mode`, { method: 'POST', diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 672abe48..4b2bc5bb 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -19,6 +19,25 @@ function SettingsIcon() { ) } +function SwitchToRemoteIcon() { + return ( + + + + + ) +} + function AbortIcon(props: { spinning: boolean }) { if (props.spinning) { return ( @@ -79,6 +98,10 @@ export function ComposerButtons(props: { abortDisabled: boolean isAborting: boolean onAbort: () => void + showSwitchButton: boolean + switchDisabled: boolean + isSwitching: boolean + onSwitch: () => void }) { return (
@@ -108,6 +131,19 @@ export function ComposerButtons(props: { ) : null} + + {props.showSwitchButton ? ( + + ) : null}
void onModelModeChange?: (mode: ModelMode) => void + onSwitchToRemote?: () => void autocompletePrefixes?: string[] autocompleteSuggestions?: (query: string) => Promise }) { @@ -63,8 +65,10 @@ export function HappyComposer(props: { thinking = false, agentState, contextSize, + controlledByUser = false, onPermissionModeChange, onModelModeChange, + onSwitchToRemote, autocompletePrefixes = ['@', '/'], autocompleteSuggestions = defaultSuggestionHandler } = props @@ -85,6 +89,7 @@ export function HappyComposer(props: { }) const [showSettings, setShowSettings] = useState(false) const [isAborting, setIsAborting] = useState(false) + const [isSwitching, setIsSwitching] = useState(false) const textareaRef = useRef(null) @@ -146,6 +151,8 @@ export function HappyComposer(props: { }, [api, suggestions, inputState, autocompletePrefixes, haptic]) const abortDisabled = controlsDisabled || isAborting || !threadIsRunning + const switchDisabled = controlsDisabled || isSwitching || !controlledByUser + const showSwitchButton = Boolean(controlledByUser && onSwitchToRemote) useEffect(() => { if (!isAborting) return @@ -153,6 +160,12 @@ export function HappyComposer(props: { setIsAborting(false) }, [isAborting, threadIsRunning]) + useEffect(() => { + if (!isSwitching) return + if (controlledByUser) return + setIsSwitching(false) + }, [isSwitching, controlledByUser]) + const handleAbort = useCallback(() => { if (abortDisabled) return haptic('error') @@ -160,6 +173,17 @@ export function HappyComposer(props: { api.thread().cancelRun() }, [abortDisabled, api, haptic]) + const handleSwitch = useCallback(async () => { + if (switchDisabled || !onSwitchToRemote) return + haptic('light') + setIsSwitching(true) + try { + await onSwitchToRemote() + } catch { + setIsSwitching(false) + } + }, [switchDisabled, onSwitchToRemote, haptic]) + const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { const key = e.key @@ -430,6 +454,10 @@ export function HappyComposer(props: { abortDisabled={abortDisabled} isAborting={isAborting} onAbort={handleAbort} + showSwitchButton={showSwitchButton} + switchDisabled={switchDisabled} + isSwitching={isSwitching} + onSwitch={handleSwitch} /> diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 12c1b535..a6ad8e75 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -35,7 +35,7 @@ export function SessionChat(props: { const controlsDisabled = !props.session.active const normalizedCacheRef = useRef>(new Map()) const blocksByIdRef = useRef>(new Map()) - const { abortSession, setPermissionMode, setModelMode } = useSessionActions(props.api, props.session.id) + const { abortSession, switchSession, setPermissionMode, setModelMode } = useSessionActions(props.api, props.session.id) useEffect(() => { normalizedCacheRef.current.clear() @@ -108,6 +108,12 @@ export function SessionChat(props: { props.onRefresh() }, [abortSession, props.onRefresh]) + // Switch to remote handler + const handleSwitchToRemote = useCallback(async () => { + await switchSession() + props.onRefresh() + }, [switchSession, props.onRefresh]) + const handleViewFiles = useCallback(() => { navigate({ to: '/sessions/$sessionId/files', @@ -204,8 +210,10 @@ export function SessionChat(props: { thinking={props.session.thinking} agentState={props.session.agentState} contextSize={reduced.latestUsage?.contextSize} + controlledByUser={props.session.agentState?.controlledByUser === true} onPermissionModeChange={handlePermissionModeChange} onModelModeChange={handleModelModeChange} + onSwitchToRemote={handleSwitchToRemote} /> diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 279ada74..5fb88904 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -22,6 +22,7 @@ function toModelMode(mode: ModelMode): ModelModeValue { export function useSessionActions(api: ApiClient | null, sessionId: string | null): { abortSession: () => Promise + switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setModelMode: (mode: ModelMode) => Promise isPending: boolean @@ -44,6 +45,16 @@ export function useSessionActions(api: ApiClient | null, sessionId: string | nul onSuccess: () => void invalidateSession(), }) + const switchMutation = useMutation({ + mutationFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + await api.switchSession(sessionId) + }, + onSuccess: () => void invalidateSession(), + }) + const permissionMutation = useMutation({ mutationFn: async (mode: PermissionMode) => { if (!api || !sessionId) { @@ -66,8 +77,9 @@ export function useSessionActions(api: ApiClient | null, sessionId: string | nul return { abortSession: abortMutation.mutateAsync, + switchSession: switchMutation.mutateAsync, setPermissionMode: permissionMutation.mutateAsync, setModelMode: modelMutation.mutateAsync, - isPending: abortMutation.isPending || permissionMutation.isPending || modelMutation.isPending, + isPending: abortMutation.isPending || switchMutation.isPending || permissionMutation.isPending || modelMutation.isPending, } }